Added unsubscribe_url to member api response (#21207)

ref https://linear.app/tryghost/issue/ONC-387/

With some recent changes, we added validation to unsubscribe URLs to verify the source, allowing us to cut down on spam and improving security, as the underlying key could be re-generated should the need arise. This had the side effect of making unsubscribe URLs difficult to reconstruct when using third-party/downstream integrations, such as ActiveCampaign, which fills a gap in the current Ghost feature set.

Now any authenticated query to `/api/members` will return an `unsubscribe_url` field that can be used directly.
This commit is contained in:
Steve Larson 2024-10-16 14:00:31 -05:00 committed by GitHub
parent a0600e3595
commit 63f25ece6d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
24 changed files with 561 additions and 178 deletions

View File

@ -167,7 +167,8 @@ function serializeMember(member, options) {
email_recipients: json.email_recipients,
status: json.status,
last_seen_at: json.last_seen_at,
attribution: serializeAttribution(json.attribution)
attribution: serializeAttribution(json.attribution),
unsubscribe_url: json.unsubscribe_url
};
if (json.products) {

View File

@ -1,5 +1,6 @@
const stripeService = require('../stripe');
const settingsCache = require('../../../shared/settings-cache');
const settingsHelpers = require('../../services/settings-helpers');
const MembersApi = require('@tryghost/members-api');
const logging = require('@tryghost/logging');
const mail = require('../mail');
@ -236,7 +237,8 @@ function createApiInstance(config) {
memberAttributionService: memberAttributionService.service,
emailSuppressionList,
settingsCache,
sentry
sentry,
settingsHelpers
});
return membersApiInstance;

View File

@ -22,6 +22,7 @@ module.exports.formattedMemberResponse = function formattedMemberResponse(member
firstname: member.name && member.name.split(' ')[0],
expertise: member.expertise,
avatar_image: member.avatar_image,
unsubscribe_url: member.unsubscribe_url,
subscribed: !!member.subscribed,
subscriptions: member.subscriptions || [],
paid: member.status !== 'free',

View File

@ -2,6 +2,7 @@ const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const {EmailAddressParser} = require('@tryghost/email-addresses');
const logging = require('@tryghost/logging');
const crypto = require('crypto');
const messages = {
incorrectKeyType: 'type must be one of "direct" or "connect".'
@ -179,6 +180,30 @@ class SettingsHelpers {
return this.#managedEmailEnabled() || this.labs.isSet('newEmailAddresses');
}
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;
}
// PRIVATE
#managedEmailEnabled() {

View File

@ -122,6 +122,7 @@ Object {
"yearly_price_id": Any<String>,
},
],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -133,7 +134,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription 2: [headers]
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2484",
"content-length": "2573",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -225,6 +226,7 @@ Object {
},
],
"tiers": Array [],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -236,7 +238,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription 4: [headers]
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1584",
"content-length": "1673",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -272,6 +274,7 @@ Object {
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -283,7 +286,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3635",
"content-length": "3724",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -319,6 +322,7 @@ Object {
"subscribed": false,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -330,7 +334,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2735",
"content-length": "2824",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -374,6 +378,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -385,7 +390,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3425",
"content-length": "3514",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -429,6 +434,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -440,7 +446,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2525",
"content-length": "2614",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -484,6 +490,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -495,7 +502,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3434",
"content-length": "3523",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -539,6 +546,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -550,7 +558,7 @@ exports[`Members API: edit subscriptions Can cancel a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2534",
"content-length": "2623",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -839,6 +847,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -850,7 +859,7 @@ exports[`Members API: edit subscriptions Can recover member products when we can
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "3467",
"content-length": "3556",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -894,6 +903,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -905,7 +915,7 @@ exports[`Members API: edit subscriptions Can recover member products when we can
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2567",
"content-length": "2656",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -949,6 +959,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -960,7 +971,7 @@ exports[`Members API: edit subscriptions Can recover member products when we upd
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "4222",
"content-length": "4311",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -1004,6 +1015,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -1015,7 +1027,7 @@ exports[`Members API: edit subscriptions Can update a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "4201",
"content-length": "4290",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -1059,6 +1071,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -1070,7 +1083,7 @@ exports[`Members API: edit subscriptions Can update a subscription for a member
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "4200",
"content-length": "4289",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -25,6 +25,7 @@ Object {
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -50,6 +51,7 @@ Object {
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -71,7 +73,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2229",
"content-length": "2407",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -105,6 +107,7 @@ Object {
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -130,6 +133,7 @@ Object {
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -155,6 +159,7 @@ Object {
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -180,6 +185,7 @@ Object {
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -205,6 +211,7 @@ Object {
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -230,6 +237,7 @@ Object {
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -251,7 +259,7 @@ exports[`Members API - With Newsletters - compat mode Can fetch members who are
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "7972",
"content-length": "8506",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -285,6 +293,7 @@ Object {
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -310,6 +319,7 @@ Object {
"status": "paid",
"subscribed": false,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -331,7 +341,7 @@ exports[`Members API - With Newsletters Can fetch members who are NOT subscribed
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2229",
"content-length": "2407",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -365,6 +375,7 @@ Object {
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -390,6 +401,7 @@ Object {
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -415,6 +427,7 @@ Object {
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -440,6 +453,7 @@ Object {
"status": "paid",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -465,6 +479,7 @@ Object {
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -490,6 +505,7 @@ Object {
"status": "free",
"subscribed": true,
"subscriptions": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -511,7 +527,7 @@ exports[`Members API - With Newsletters Can fetch members who are subscribed 2:
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "7972",
"content-length": "8506",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -5,6 +5,8 @@ const assert = require('assert/strict');
const models = require('../../../core/server/models');
const {stripeMocker} = require('../../utils/e2e-framework-mock-manager');
const DomainEvents = require('@tryghost/domain-events/lib/DomainEvents');
const settingsHelpers = require('../../../core/server/services/settings-helpers');
const sinon = require('sinon');
const subscriptionSnapshot = {
id: anyString,
@ -49,6 +51,7 @@ describe('Members API: edit subscriptions', function () {
});
beforeEach(function () {
sinon.stub(settingsHelpers, 'createUnsubscribeUrl').returns('http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme'); // member uuid changes with every test run
mockManager.mockStripe();
mockManager.mockMail();
});

View File

@ -1,5 +1,7 @@
const {agentProvider, mockManager, fixtureManager, matchers} = require('../../utils/e2e-framework');
const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyISODateTime, anyArray} = matchers;
const settingsHelpers = require('../../../core/server/services/settings-helpers');
const sinon = require('sinon');
const memberMatcherShallowIncludesForNewsletters = {
id: anyObjectId,
@ -20,6 +22,10 @@ describe('Members API - With Newsletters', function () {
await agent.loginAsOwner();
});
beforeEach(function () {
sinon.stub(settingsHelpers, 'createUnsubscribeUrl').returns('http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme');
});
afterEach(function () {
mockManager.restore();
});
@ -60,6 +66,10 @@ describe('Members API - With Newsletters - compat mode', function () {
await agent.loginAsOwner();
});
beforeEach(function () {
sinon.stub(settingsHelpers, 'createUnsubscribeUrl').returns('http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme');
});
afterEach(function () {
mockManager.restore();
});

View File

@ -22,6 +22,7 @@ const settingsCache = require('../../../core/shared/settings-cache');
const DomainEvents = require('@tryghost/domain-events');
const logging = require('@tryghost/logging');
const {stripeMocker, mockLabsDisabled} = require('../../utils/e2e-framework-mock-manager');
const settingsHelpers = require('../../../core/server/services/settings-helpers');
/**
* Assert that haystack and needles match, ignoring the order.
@ -136,7 +137,8 @@ function buildMemberWithIncludesSnapshot(options) {
attribution: attributionSnapshot,
newsletters: new Array(options.newsletters).fill(newsletterSnapshot),
subscriptions: anyArray,
labels: anyArray
labels: anyArray,
unsubscribe_url: anyString
};
}
@ -154,7 +156,8 @@ const memberMatcherShallowIncludes = {
created_at: anyISODateTime,
updated_at: anyISODateTime,
subscriptions: anyArray,
labels: anyArray
labels: anyArray,
unsubscribe_url: anyString
};
/**
@ -487,13 +490,14 @@ describe('Members API', function () {
agent = await agentProvider.getAdminAPIAgent();
await fixtureManager.init('posts', 'newsletters', 'members:newsletters', 'comments', 'redirects', 'clicks');
await agent.loginAsOwner();
newsletters = await getNewsletters();
});
beforeEach(function () {
mockManager.mockStripe();
emailMockReceiver = mockManager.mockMail();
sinon.stub(settingsHelpers, 'createUnsubscribeUrl').returns('http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme');
});
afterEach(function () {

View File

@ -1668,6 +1668,94 @@ Object {
}
`;
exports[`Comments API when commenting enabled for all when authenticated Browsing comments does not return the member unsubscribe_url 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": "<p>This is a comment</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"expertise": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Egon Spengler",
"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",
},
Object {
"count": Object {
"likes": Any<Number>,
"replies": Any<Number>,
},
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a comment</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"expertise": null,
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"name": "Mr Egg",
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
"replies": 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": "<p>This is a reply</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"member": Object {
"avatar_image": null,
"expertise": 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",
},
],
"status": "published",
},
],
"meta": Object {
"pagination": Object {
"limit": 15,
"next": null,
"page": 1,
"pages": 1,
"prev": null,
"total": 2,
},
},
}
`;
exports[`Comments API when commenting enabled for all when authenticated Browsing comments does not return the member unsubscribe_url 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": "1118",
"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 browse all comments of a post (legacy) 1: [body] 1`] = `
Object {
"comments": Array [

View File

@ -501,6 +501,15 @@ describe('Comments API', function () {
]);
});
it('Browsing comments does not return the member unsubscribe_url', async function () {
await setupBrowseCommentsData();
const response = await testGetComments(`/api/comments/post/${postId}/`, [
commentMatcher,
commentMatcherWithReplies({replies: 1})
]);
should.not.exist(response.body.comments[0].unsubscribe_url);
});
it('Can reply to your own comment', async function () {
// Should not update last_seen_at or last_commented_at when both are already set to a value on the same day
const timezone = settingsCache.get('timezone');

View File

@ -32,6 +32,7 @@ Object {
"paid": false,
"subscribed": false,
"subscriptions": Array [],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
}
`;
@ -40,7 +41,7 @@ exports[`Comments API when authenticated can get member data 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": "621",
"content-length": "710",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -159,6 +160,7 @@ Object {
"paid": false,
"subscribed": false,
"subscriptions": Array [],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
}
`;
@ -167,7 +169,7 @@ exports[`Comments API when authenticated can update comment notifications 2: [he
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": "633",
"content-length": "722",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -245,6 +247,7 @@ Object {
"paid": false,
"subscribed": false,
"subscriptions": Array [],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
}
`;
@ -253,7 +256,7 @@ exports[`Comments API when authenticated can update member expertise 2: [headers
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": "634",
"content-length": "723",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -293,6 +296,7 @@ Object {
"paid": false,
"subscribed": false,
"subscriptions": Array [],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
}
`;
@ -301,7 +305,7 @@ exports[`Comments API when authenticated can update name 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": "632",
"content-length": "721",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -341,6 +345,7 @@ Object {
"paid": false,
"subscribed": false,
"subscriptions": Array [],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
}
`;
@ -349,7 +354,7 @@ exports[`Comments API when authenticated trims whitespace from expertise 2: [hea
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": "623",
"content-length": "712",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -441,6 +446,7 @@ Object {
"paid": false,
"subscribed": false,
"subscriptions": Array [],
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
}
`;
@ -449,7 +455,7 @@ exports[`Comments API when caching members content is enabled sets ghost-access
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": "621",
"content-length": "710",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"set-cookie": Array [

View File

@ -27,6 +27,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -38,7 +39,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2550",
"content-length": "2639",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -74,6 +75,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -85,7 +87,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2564",
"content-length": "2653",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -121,6 +123,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -132,7 +135,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2452",
"content-length": "2541",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -168,6 +171,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -179,7 +183,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2612",
"content-length": "2701",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -215,6 +219,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -226,7 +231,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2578",
"content-length": "2667",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -262,6 +267,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -273,7 +279,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2592",
"content-length": "2681",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -309,6 +315,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -320,7 +327,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent with
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2506",
"content-length": "2595",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -356,6 +363,7 @@ Object {
"subscribed": true,
"subscriptions": Any<Array>,
"tiers": Any<Array>,
"unsubscribe_url": "http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme",
"updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"uuid": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
},
@ -367,7 +375,7 @@ exports[`Members API Member attribution Creates a SubscriptionCreatedEvent witho
Object {
"access-control-allow-origin": "http://127.0.0.1:2369",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "2452",
"content-length": "2541",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -42,6 +42,7 @@ describe('Comments API', function () {
});
beforeEach(function () {
sinon.stub(settingsHelpers, 'createUnsubscribeUrl').returns('http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme');
mockManager.mockMail();
});

View File

@ -10,6 +10,8 @@ const urlService = require('../../../core/server/services/url');
const urlUtils = require('../../../core/shared/url-utils');
const DomainEvents = require('@tryghost/domain-events');
const {anyContentVersion, anyEtag, anyObjectId, anyUuid, anyISODateTime, anyString, anyArray, anyObject} = matchers;
const settingsHelpers = require('../../../core/server/services/settings-helpers');
const sinon = require('sinon');
let membersAgent;
let adminAgent;
@ -120,6 +122,8 @@ describe('Members API', function () {
return [500];
});
sinon.stub(settingsHelpers, 'createUnsubscribeUrl').returns('http://domain.com/unsubscribe/?uuid=memberuuid&key=abc123dontstealme');
});
afterEach(function () {

View File

@ -829,9 +829,10 @@ describe('Front-end members behavior', function () {
'created_at',
'enable_comment_notifications',
'newsletters',
'email_suppression'
'email_suppression',
'unsubscribe_url'
]);
Object.keys(memberData).should.have.length(13);
Object.keys(memberData).should.have.length(14);
memberData.should.not.have.property('id');
memberData.newsletters.should.have.length(1);

View File

@ -28,6 +28,7 @@ describe('Members Service - utils', function () {
suppressed: false,
info: null
},
unsubscribe_url: undefined,
created_at: '2020-01-01T00:00:00.000Z'
});
should(member1).deepEqual({
@ -37,6 +38,7 @@ describe('Members Service - utils', function () {
expertise: null,
firstname: 'Jamie',
avatar_image: 'https://gravatar.com/avatar/7d8efd2c2a781111599a8cae293cf704?s=250&d=blank',
unsubscribe_url: undefined,
subscribed: true,
subscriptions: [],
paid: false,
@ -69,6 +71,7 @@ describe('Members Service - utils', function () {
sort_order: 0
}],
enable_comment_notifications: false,
unsubscribe_url: undefined,
created_at: '2020-01-01T00:00:00.000Z'
});
should(member1).deepEqual({
@ -89,6 +92,7 @@ describe('Members Service - utils', function () {
sort_order: 0
}],
enable_comment_notifications: false,
unsubscribe_url: undefined,
created_at: '2020-01-01T00:00:00.000Z'
});
});

View File

@ -2,6 +2,9 @@ const should = require('should');
const sinon = require('sinon');
const configUtils = require('../../../../utils/configUtils');
const SettingsHelpers = require('../../../../../core/server/services/settings-helpers/SettingsHelpers');
const crypto = require('crypto');
const mockValidationKey = 'validation_key';
function createSettingsMock({setDirect, setConnect}) {
const getStub = sinon.stub();
@ -27,67 +30,122 @@ function createSettingsMock({setDirect, setConnect}) {
getStub.withArgs('stripe_connect_display_name').returns('Test');
getStub.withArgs('stripe_connect_account_id').returns('ac_XXXXXXXXXXXXX');
getStub.withArgs('members_email_auth_secret').returns(mockValidationKey);
return {
get: getStub
};
}
describe('Settings Helpers - getActiveStripeKeys', function () {
beforeEach(function () {
configUtils.set({
url: 'http://domain.tld/subdir',
admin: {url: 'http://sub.domain.tld'}
describe('Settings Helpers', function () {
describe('getActiveStripeKeys', function () {
beforeEach(function () {
configUtils.set({
url: 'http://domain.tld/subdir',
admin: {url: 'http://sub.domain.tld'}
});
});
afterEach(async function () {
await 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');
});
});
afterEach(async function () {
await configUtils.restore();
describe('getMembersValidationKey', function () {
it('returns a key that can be used to validate members', function () {
const fakeSettings = createSettingsMock({setDirect: true, setConnect: true});
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils: {}});
const key = settingsHelpers.getMembersValidationKey();
should.equal(key, 'validation_key');
});
});
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
describe('createUnsubscribeUrl', function () {
const memberUuid = 'memberuuid';
const newsletterUuid = 'newsletteruuid';
const urlUtils = {
urlFor: sinon.stub().returns('http://domain.com/')
};
const memberUuidHash = crypto.createHmac('sha256', mockValidationKey).update(`${memberUuid}`).digest('hex');
let fakeSettings;
before(function () {
fakeSettings = createSettingsMock({setDirect: true, setConnect: 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
afterEach(async function () {
await configUtils.restore();
});
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
it('returns a generic unsubscribe url when no uuid is provided', function () {
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils});
const url = settingsHelpers.createUnsubscribeUrl(null);
should.equal(url, 'http://domain.com/unsubscribe/?preview=1');
});
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
it('returns a url that can be used to unsubscribe a member', function () {
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils});
const url = settingsHelpers.createUnsubscribeUrl(memberUuid);
should.equal(url, `http://domain.com/unsubscribe/?uuid=memberuuid&key=${memberUuidHash}`);
});
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('returns a url that can be used to unsubscribe a member for a given newsletter', function () {
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils});
const url = settingsHelpers.createUnsubscribeUrl(memberUuid, {newsletterUuid});
should.equal(url, `http://domain.com/unsubscribe/?uuid=memberuuid&key=${memberUuidHash}&newsletter=newsletteruuid`);
});
it('returns a url that can be used to unsubscribe a member from comments', function () {
const settingsHelpers = new SettingsHelpers({settingsCache: fakeSettings, config: configUtils.config, urlUtils});
const url = settingsHelpers.createUnsubscribeUrl(memberUuid, {comments: true});
should.equal(url, `http://domain.com/unsubscribe/?uuid=memberuuid&key=${memberUuidHash}&comments=1`);
});
});
});

View File

@ -123,7 +123,7 @@ class EmailRenderer {
/**
* @param {object} dependencies
* @param {object} dependencies.settingsCache
* @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string, getMembersValidationKey(): string}} dependencies.settingsHelpers
* @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string, getMembersValidationKey(): string, createUnsubscribeUrl(uuid: string, options: object): string}} dependencies.settingsHelpers
* @param {object} dependencies.renderers
* @param {{render(object, options): string}} dependencies.renderers.lexical
* @param {{render(object, options): string}} dependencies.renderers.mobiledoc
@ -505,27 +505,7 @@ class EmailRenderer {
* @param {boolean} [options.comments] Unsubscribe from comment emails
*/
createUnsubscribeUrl(uuid, options = {}) {
const siteUrl = this.#urlUtils.urlFor('home', true);
const unsubscribeUrl = new URL(siteUrl);
const key = this.#settingsHelpers.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;
return this.#settingsHelpers.createUnsubscribeUrl(uuid, options);
}
/**

View File

@ -61,6 +61,10 @@ async function validateHtml(html) {
assert.equal(report.valid, true, 'Expected valid HTML without warnings, got errors:\n' + parsedErrors.join('\n\n'));
}
const createUnsubscribeUrl = (uuid) => {
return `https://example.com/unsubscribe/?uuid=${uuid}&key=456`;
};
const getMembersValidationKey = () => {
return 'members-key';
};
@ -98,7 +102,7 @@ describe('Email renderer', function () {
}
}
},
settingsHelpers: {getMembersValidationKey}
settingsHelpers: {getMembersValidationKey,createUnsubscribeUrl}
});
newsletter = createModel({
uuid: 'newsletteruuid'
@ -119,8 +123,8 @@ describe('Email renderer', function () {
assert.equal(replacements.length, 1);
assert.equal(replacements[0].token.toString(), '/%%\\{list_unsubscribe\\}%%/g');
assert.equal(replacements[0].id, 'list_unsubscribe');
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex');
assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=${member.uuid}&key=${memberHmac}&newsletter=newsletteruuid`);
const unsubscribeUrl = createUnsubscribeUrl(member.uuid);
assert.equal(replacements[0].getValue(member), unsubscribeUrl);
});
it('returns a replacement if it is used', function () {
@ -156,8 +160,8 @@ describe('Email renderer', function () {
assert.equal(replacements.length, 2);
assert.equal(replacements[0].token.toString(), '/%%\\{unsubscribe_url\\}%%/g');
assert.equal(replacements[0].id, 'unsubscribe_url');
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex');
assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=${member.uuid}&key=${memberHmac}&newsletter=newsletteruuid`);
const unsubscribeUrl = createUnsubscribeUrl(member.uuid);
assert.equal(replacements[0].getValue(member), unsubscribeUrl);
});
it('returns correct name', function () {
@ -2303,7 +2307,8 @@ describe('Email renderer', function () {
}
},
settingsHelpers: {
getMembersValidationKey
getMembersValidationKey,
createUnsubscribeUrl
}
});
});
@ -2312,21 +2317,26 @@ describe('Email renderer', function () {
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
newsletterUuid: 'newsletteruuid'
});
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update('memberuuid').digest('hex');
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&key=${memberHmac}&newsletter=newsletteruuid`);
const unsubscribeUrl = createUnsubscribeUrl('memberuuid', {
newsletterUuid: 'newsletteruuid'
});
assert.equal(response, unsubscribeUrl);
});
it('includes comments', async function () {
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
comments: true
});
const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update('memberuuid').digest('hex');
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&key=${memberHmac}&comments=1`);
const unsubscribeUrl = createUnsubscribeUrl('memberuuid', {
comments: true
});
assert.equal(response, unsubscribeUrl);
});
it('works for previews', async function () {
const response = await emailRenderer.createUnsubscribeUrl();
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?preview=1`);
const unsubscribeUrl = createUnsubscribeUrl();
assert.equal(response, unsubscribeUrl);
});
});

View File

@ -72,7 +72,8 @@ module.exports = function MembersAPI({
memberAttributionService,
emailSuppressionList,
settingsCache,
sentry
sentry,
settingsHelpers
}) {
const tokenService = new TokenService({
privateKey,
@ -144,7 +145,8 @@ module.exports = function MembersAPI({
labsService,
stripeService: stripeAPIService,
memberAttributionService,
emailSuppressionList
emailSuppressionList,
settingsHelpers
});
const geolocationService = new GeolocationService();

View File

@ -37,8 +37,9 @@ module.exports = class MemberBREADService {
* @param {IStripeService} deps.stripeService
* @param {import('@tryghost/member-attribution/lib/service')} deps.memberAttributionService
* @param {import('@tryghost/email-suppression-list/lib/email-suppression-list').IEmailSuppressionList} deps.emailSuppressionList
* @param {import('@tryghost/settings-helpers')} deps.settingsHelpers
*/
constructor({memberRepository, labsService, emailService, stripeService, offersAPI, memberAttributionService, emailSuppressionList}) {
constructor({memberRepository, labsService, emailService, stripeService, offersAPI, memberAttributionService, emailSuppressionList, settingsHelpers}) {
this.offersAPI = offersAPI;
/** @private */
this.memberRepository = memberRepository;
@ -52,6 +53,8 @@ module.exports = class MemberBREADService {
this.memberAttributionService = memberAttributionService;
/** @private */
this.emailSuppressionList = emailSuppressionList;
/** @private */
this.settingsHelpers = settingsHelpers;
}
/**
@ -246,6 +249,9 @@ module.exports = class MemberBREADService {
info: suppressionData.info
};
const unsubscribeUrl = this.settingsHelpers.createUnsubscribeUrl(member.id);
member.unsubscribe_url = unsubscribeUrl;
return member;
}
@ -426,6 +432,7 @@ module.exports = class MemberBREADService {
suppressed: bulkSuppressionData[index].suppressed || !!model.get('email_disabled'),
info: bulkSuppressionData[index].info
};
member.unsubscribe_url = this.settingsHelpers.createUnsubscribeUrl(member.id);
return member;
});

View File

@ -26,6 +26,9 @@ describe('MemberBreadService', function () {
const getService = () => {
return new MemberBreadService({
settingsHelpers: {
createUnsubscribeUrl: sinon.stub().returns('https://example.com/unsubscribe/?uuid=123&key=456')
},
memberRepository: memberRepositoryStub,
memberAttributionService: memberAttributionServiceStub,
emailSuppressionList: emailSuppressionListStub
@ -286,5 +289,12 @@ describe('MemberBreadService', function () {
info: 'bounce'
});
});
it('returns a member with an unsubscribe url', async function () {
const memberBreadService = getService();
const member = await memberBreadService.read({id: MEMBER_ID});
assert.equal(member.unsubscribe_url, 'https://example.com/unsubscribe/?uuid=123&key=456');
});
});
});