From dac25612520b571f58679764ecc27109e641d1db Mon Sep 17 00:00:00 2001
From: Steve Larson <9larsons@gmail.com>
Date: Mon, 29 Jul 2024 15:49:40 -0500
Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=20Added=20uuid=20verification=20to?=
=?UTF-8?q?=20member=20endpoints=20not=20requiring=20a=20session?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
ref https://linear.app/tryghost/issue/ENG-1364
ref https://linear.app/tryghost/issue/ENG-1464
- credits to https://github.com/1337Nerd
- added a hashed value to endpoints that do not require a member sign in in order to verify the source of the link and resulting request
- added redirect to sign in page when trying to access newsletter
management
---
apps/portal/package.json | 2 +-
apps/portal/src/App.js | 70 ++++--
.../src/components/pages/AccountEmailPage.js | 76 +++++-
.../components/pages/AccountEmailPage.test.js | 2 +-
.../src/components/pages/FeedbackPage.js | 14 +-
.../src/components/pages/FeedbackPage.test.js | 40 +++
.../src/components/pages/LoadingPage.js | 2 +-
.../src/components/pages/UnsubscribePage.js | 10 +-
.../src/tests/EmailSubscriptionsFlow.test.js | 61 ++++-
apps/portal/src/tests/FeedbackFlow.test.js | 181 ++++++++++++++
apps/portal/src/utils/api.js | 15 +-
apps/portal/src/utils/fixtures-generator.js | 31 ++-
apps/portal/src/utils/fixtures.js | 23 ++
.../lib/AudienceFeedbackService.js | 5 +-
.../test/AudienceFeedbackService.test.js | 11 +-
.../routing/controllers/unsubscribe.js | 16 ++
.../server/services/members/middleware.js | 51 ++--
.../settings-helpers/SettingsHelpers.js | 10 +
ghost/core/core/server/web/members/app.js | 13 +-
ghost/core/core/shared/config/defaults.json | 2 +-
.../shared/config/env/config.development.json | 2 +-
.../__snapshots__/email-previews.test.js.snap | 30 +--
.../admin/__snapshots__/emails.test.js.snap | 6 +-
.../test/e2e-api/admin/email-previews.test.js | 2 +
ghost/core/test/e2e-api/admin/emails.test.js | 2 +
.../__snapshots__/feedback.test.js.snap | 98 ++++++++
.../test/e2e-api/members/feedback.test.js | 107 ++++++--
.../test/e2e-api/members/middleware.test.js | 12 +-
ghost/core/test/e2e-frontend/members.test.js | 235 +++++++++++-------
.../services/members/middleware.test.js | 34 ++-
ghost/email-service/lib/EmailRenderer.js | 24 +-
.../email-service/test/email-renderer.test.js | 126 ++++++----
32 files changed, 1025 insertions(+), 288 deletions(-)
create mode 100644 apps/portal/src/components/pages/FeedbackPage.test.js
create mode 100644 apps/portal/src/tests/FeedbackFlow.test.js
diff --git a/apps/portal/package.json b/apps/portal/package.json
index c1f630e2ef..99ae1813a2 100644
--- a/apps/portal/package.json
+++ b/apps/portal/package.json
@@ -1,6 +1,6 @@
{
"name": "@tryghost/portal",
- "version": "2.38.0",
+ "version": "2.39.0",
"license": "MIT",
"repository": {
"type": "git",
diff --git a/apps/portal/src/App.js b/apps/portal/src/App.js
index 66b920872d..2e3d80d493 100644
--- a/apps/portal/src/App.js
+++ b/apps/portal/src/App.js
@@ -211,7 +211,7 @@ export default class App extends React.Component {
async fetchData() {
const {site: apiSiteData, member} = await this.fetchApiData();
const {site: devSiteData, ...restDevData} = this.fetchDevData();
- const {site: linkSiteData, ...restLinkData} = this.fetchLinkData(apiSiteData);
+ const {site: linkSiteData, ...restLinkData} = this.fetchLinkData(apiSiteData, member);
const {site: previewSiteData, ...restPreviewData} = this.fetchPreviewData();
const {site: notificationSiteData, ...restNotificationData} = this.fetchNotificationData();
let page = '';
@@ -420,18 +420,32 @@ export default class App extends React.Component {
}
/** Fetch state from Portal Links */
- fetchLinkData(site) {
+ fetchLinkData(site, member) {
const qParams = new URLSearchParams(window.location.search);
- if (qParams.get('uuid') && qParams.get('action') === 'unsubscribe') {
- return {
- showPopup: true,
- page: 'unsubscribe',
- pageData: {
- uuid: qParams.get('uuid'),
- newsletterUuid: qParams.get('newsletter'),
- comments: qParams.get('comments')
- }
- };
+ if (qParams.get('action') === 'unsubscribe') {
+ // if the user is unsubscribing from a newsletter with an old unsubscribe link that we can't validate, push them to newsletter mgmt where they have to log in
+ if (qParams.get('key') && qParams.get('uuid')) {
+ return {
+ showPopup: true,
+ page: 'unsubscribe',
+ pageData: {
+ uuid: qParams.get('uuid'),
+ key: qParams.get('key'),
+ newsletterUuid: qParams.get('newsletter'),
+ comments: qParams.get('comments')
+ }
+ };
+ } else { // any malformed unsubscribe links should simply go to email prefs
+ return {
+ showPopup: true,
+ page: 'accountEmail',
+ pageData: {
+ newsletterUuid: qParams.get('newsletter'),
+ action: 'unsubscribe',
+ redirect: site.url + '#/portal/account/newsletters'
+ }
+ };
+ }
}
if (hasRecommendations({site}) && qParams.get('action') === 'signup' && qParams.get('success') === 'true') {
@@ -453,19 +467,31 @@ export default class App extends React.Component {
const linkRegex = /^\/portal\/?(?:\/(\w+(?:\/\w+)*))?\/?$/;
const feedbackRegex = /^\/feedback\/(\w+?)\/(\w+?)\/?$/;
- if (path && feedbackRegex.test(path) && hashQuery.get('uuid')) {
+ if (path && feedbackRegex.test(path)) {
const [, postId, scoreString] = path.match(feedbackRegex);
const score = parseInt(scoreString);
if (score === 1 || score === 0) {
- return {
- showPopup: true,
- page: 'feedback',
- pageData: {
- uuid: hashQuery.get('uuid'),
- postId,
- score
- }
- };
+ // if logged in, submit feedback
+ if (member || (hashQuery.get('uuid') && hashQuery.get('key'))) {
+ return {
+ showPopup: true,
+ page: 'feedback',
+ pageData: {
+ uuid: member ? null : hashQuery.get('uuid'),
+ key: member ? null : hashQuery.get('key'),
+ postId,
+ score
+ }
+ };
+ } else {
+ return {
+ showPopup: true,
+ page: 'signin',
+ pageData: {
+ redirect: site.url + `#/feedback/${postId}/${score}/`
+ }
+ };
+ }
}
}
if (path && linkRegex.test(path)) {
diff --git a/apps/portal/src/components/pages/AccountEmailPage.js b/apps/portal/src/components/pages/AccountEmailPage.js
index ed31a676f4..f8466b0caa 100644
--- a/apps/portal/src/components/pages/AccountEmailPage.js
+++ b/apps/portal/src/components/pages/AccountEmailPage.js
@@ -1,22 +1,84 @@
import AppContext from '../../AppContext';
import {useContext, useEffect, useState} from 'react';
-import {isPaidMember} from '../../utils/helpers';
+import {isPaidMember, getSiteNewsletters} from '../../utils/helpers';
+import {SYNTAX_I18NEXT} from '@doist/react-interpolate';
import NewsletterManagement from '../common/NewsletterManagement';
+import Interpolate from '@doist/react-interpolate';
export default function AccountEmailPage() {
- const {member, onAction, site, t} = useContext(AppContext);
+ const {member, onAction, site, t, pageData} = useContext(AppContext);
+ let newsletterUuid;
+ let action;
+ if (pageData) {
+ newsletterUuid = pageData.newsletterUuid;
+ action = pageData.action;
+ }
+ const [hasInteracted, setHasInteracted] = useState(true);
+ const siteNewsletters = getSiteNewsletters({site});
+ // Redirect to signin page if member is not available
useEffect(() => {
if (!member) {
onAction('switchPage', {
- page: 'signin',
- pageData: {
- redirect: window.location.href // This includes the search/fragment of the URL (#/portal/account) which is missing from the default referer header
- }
+ page: 'signin'
});
}
}, [member, onAction]);
+ // this results in an infinite loop, needs to run only once...
+ useEffect(() => {
+ // attempt auto-unsubscribe if we were redirected here from an unsubscribe link
+ if (newsletterUuid && action === 'unsubscribe') {
+ // Filter out the newsletter that matches the uuid
+ const remainingNewsletterSubscriptions = member?.newsletters.filter(n => n.uuid !== newsletterUuid);
+ setSubscribedNewsletters(remainingNewsletterSubscriptions);
+ setHasInteracted(false); // this shows the dialog
+ onAction('updateNewsletterPreference', {newsletters: remainingNewsletterSubscriptions});
+ }
+ }, []);
+
+ const HeaderNotification = () => {
+ if (pageData.comments && commentsEnabled) {
+ const hideClassName = hasInteracted ? 'gh-portal-hide' : '';
+ return (
+ <>
+
{
setSubscribedNewsletters(updatedNewsletters);
diff --git a/apps/portal/src/components/pages/AccountEmailPage.test.js b/apps/portal/src/components/pages/AccountEmailPage.test.js
index 8bd6a2f082..87c5f8ea57 100644
--- a/apps/portal/src/components/pages/AccountEmailPage.test.js
+++ b/apps/portal/src/components/pages/AccountEmailPage.test.js
@@ -112,6 +112,6 @@ describe('Account Email Page', () => {
newsletters: newsletterData
});
const {mockOnActionFn} = setup({site: siteData, member: null});
- expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin', pageData: {redirect: window.location.href}});
+ expect(mockOnActionFn).toHaveBeenCalledWith('switchPage', {page: 'signin'});
});
});
diff --git a/apps/portal/src/components/pages/FeedbackPage.js b/apps/portal/src/components/pages/FeedbackPage.js
index 39e701cd41..1720ac52d4 100644
--- a/apps/portal/src/components/pages/FeedbackPage.js
+++ b/apps/portal/src/components/pages/FeedbackPage.js
@@ -258,9 +258,9 @@ const ConfirmDialog = ({onConfirm, loading, initialScore}) => {
);
};
-async function sendFeedback({siteUrl, uuid, postId, score}) {
- const ghostApi = setupGhostApi({siteUrl});
- await ghostApi.feedback.add({uuid, postId, score});
+async function sendFeedback({siteUrl, uuid, key, postId, score}, api) {
+ const ghostApi = api || setupGhostApi({siteUrl});
+ await ghostApi.feedback.add({uuid, postId, key, score});
}
const LoadingFeedbackView = ({action, score}) => {
@@ -301,11 +301,10 @@ const ConfirmFeedback = ({positive}) => {
};
export default function FeedbackPage() {
- const {site, pageData, member, t} = useContext(AppContext);
- const {uuid, postId, score: initialScore} = pageData;
+ const {site, pageData, member, t, api} = useContext(AppContext);
+ const {uuid, key, postId, score: initialScore} = pageData;
const [score, setScore] = useState(initialScore);
const positive = score === 1;
-
const isLoggedIn = !!member;
const [confirmed, setConfirmed] = useState(isLoggedIn);
@@ -315,7 +314,7 @@ export default function FeedbackPage() {
const doSendFeedback = async (selectedScore) => {
setLoading(true);
try {
- await sendFeedback({siteUrl: site.url, uuid, postId, score: selectedScore});
+ await sendFeedback({siteUrl: site.url, uuid, key, postId, score: selectedScore}, api);
setScore(selectedScore);
} catch (e) {
const text = HumanReadableError.getMessageFromError(e, t('There was a problem submitting your feedback. Please try again a little later.'));
@@ -341,6 +340,5 @@ export default function FeedbackPage() {
return ;
}
}
-
return ();
}
diff --git a/apps/portal/src/components/pages/FeedbackPage.test.js b/apps/portal/src/components/pages/FeedbackPage.test.js
new file mode 100644
index 0000000000..729ed34301
--- /dev/null
+++ b/apps/portal/src/components/pages/FeedbackPage.test.js
@@ -0,0 +1,40 @@
+import {getMemberData, getSiteData} from '../../utils/fixtures-generator';
+import {render} from '../../utils/test-utils';
+import FeedbackPage from './FeedbackPage';
+
+const setup = (overrides) => {
+ const {mockOnActionFn, ...utils} = render(
+ ,
+ {
+ overrideContext: {
+ ...overrides
+ }
+ }
+ );
+ return {
+ mockOnActionFn,
+ ...utils
+ };
+};
+
+describe('FeedbackPage', () => {
+ const siteData = getSiteData();
+ const posts = siteData.posts;
+ const member = getMemberData();
+
+ // we need the API to actually test the component, so the bulk of tests will be in the FeedbackFlow file
+ test('renders', () => {
+ // mock what the larger app would process and set
+ const pageData = {
+ uuid: member.uuid,
+ key: 'key',
+ postId: posts[0].id,
+ score: 1
+ };
+ const {getByTestId} = setup({pageData});
+
+ const loaderIcon = getByTestId('loaderIcon');
+
+ expect(loaderIcon).toBeInTheDocument();
+ });
+});
diff --git a/apps/portal/src/components/pages/LoadingPage.js b/apps/portal/src/components/pages/LoadingPage.js
index 40778ef3c8..eafa7e6e64 100644
--- a/apps/portal/src/components/pages/LoadingPage.js
+++ b/apps/portal/src/components/pages/LoadingPage.js
@@ -6,7 +6,7 @@ export default class LoadingPage extends React.Component {
return (
);
diff --git a/apps/portal/src/components/pages/UnsubscribePage.js b/apps/portal/src/components/pages/UnsubscribePage.js
index 04bb6f1b5e..8717509a21 100644
--- a/apps/portal/src/components/pages/UnsubscribePage.js
+++ b/apps/portal/src/components/pages/UnsubscribePage.js
@@ -32,9 +32,9 @@ function AccountHeader() {
);
}
-async function updateMemberNewsletters({api, memberUuid, newsletters, enableCommentNotifications}) {
+async function updateMemberNewsletters({api, memberUuid, key, newsletters, enableCommentNotifications}) {
try {
- return await api.member.updateNewsletters({uuid: memberUuid, newsletters, enableCommentNotifications});
+ return await api.member.updateNewsletters({uuid: memberUuid, key, newsletters, enableCommentNotifications});
} catch (e) {
// ignore auto unsubscribe error
}
@@ -62,7 +62,7 @@ export default function UnsubscribePage() {
// when we have a member logged in, we need to update the newsletters in the context
onAction('updateNewsletterPreference', {newsletters});
} else {
- await updateMemberNewsletters({api, memberUuid: pageData.uuid, newsletters});
+ await updateMemberNewsletters({api, memberUuid: pageData.uuid, key: pageData.key, newsletters});
}
setSubscribedNewsletters(newsletters);
};
@@ -74,7 +74,7 @@ export default function UnsubscribePage() {
await onAction('updateNewsletterPreference', {enableCommentNotifications: enabled});
updatedData = {...loggedInMember, enable_comment_notifications: enabled};
} else {
- updatedData = await updateMemberNewsletters({api, memberUuid: pageData.uuid, enableCommentNotifications: enabled});
+ updatedData = await updateMemberNewsletters({api, memberUuid: pageData.uuid, key: pageData.key, enableCommentNotifications: enabled});
}
setMember(updatedData);
};
@@ -102,7 +102,7 @@ export default function UnsubscribePage() {
(async () => {
let memberData;
try {
- memberData = await api.member.newsletters({uuid: pageData.uuid});
+ memberData = await api.member.newsletters({uuid: pageData.uuid, key: pageData.key});
setMember(memberData ?? null);
setLoading(false);
} catch (e) {
diff --git a/apps/portal/src/tests/EmailSubscriptionsFlow.test.js b/apps/portal/src/tests/EmailSubscriptionsFlow.test.js
index e77fb33c27..90abd15c5b 100644
--- a/apps/portal/src/tests/EmailSubscriptionsFlow.test.js
+++ b/apps/portal/src/tests/EmailSubscriptionsFlow.test.js
@@ -1,5 +1,5 @@
import App from '../App.js';
-import {appRender, fireEvent, within} from '../utils/test-utils';
+import {appRender, fireEvent, within, waitFor} from '../utils/test-utils';
import {newsletters as Newsletters, site as FixtureSite, member as FixtureMember} from '../utils/test-fixtures';
import setupGhostApi from '../utils/api.js';
import userEvent from '@testing-library/user-event';
@@ -91,14 +91,17 @@ describe('Newsletter Subscriptions', () => {
// unsure why fireEvent has no effect here
await userEvent.click(manageSubscriptionsButton);
-
- const newsletter1 = within(popupIframeDocument).queryByText('Newsletter 1');
- const newsletter2 = within(popupIframeDocument).queryByText('Newsletter 2');
- const emailPreferences = within(popupIframeDocument).queryByText('Email preferences');
-
- expect(newsletter1).toBeInTheDocument();
- expect(newsletter2).toBeInTheDocument();
- expect(emailPreferences).toBeInTheDocument();
+
+ await waitFor(() => {
+ const newsletter1 = within(popupIframeDocument).queryByText('Newsletter 1');
+ const newsletter2 = within(popupIframeDocument).queryByText('Newsletter 2');
+ const emailPreferences = within(popupIframeDocument).queryByText('Email preferences');
+
+ // within(popupIframeDocument).getByText('dslkfjsdlk');
+ expect(newsletter1).toBeInTheDocument();
+ expect(newsletter2).toBeInTheDocument();
+ expect(emailPreferences).toBeInTheDocument();
+ });
});
test('toggle subscribing to a newsletter', async () => {
@@ -176,7 +179,7 @@ describe('Newsletter Subscriptions', () => {
test('unsubscribe via email link while not logged in', async () => {
// Mock window.location
Object.defineProperty(window, 'location', {
- value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
+ value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}&key=hashedMemberUuid`),
writable: true
});
@@ -186,10 +189,16 @@ describe('Newsletter Subscriptions', () => {
newsletters: Newsletters
}, true);
+ // Verify the API was hit to collect subscribed newsletters
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
- {uuid: FixtureMember.subbedToNewsletter.uuid}
+ {
+ uuid: FixtureMember.subbedToNewsletter.uuid,
+ key: 'hashedMemberUuid'
+ }
);
expect(popupFrame).toBeInTheDocument();
+
+ expect(within(popupIframeDocument).getByText(/will no longer receive/)).toBeInTheDocument();
// Verify the local state shows the newsletter as unsubscribed
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
let newsletter1Toggle = newsletterToggles[0];
@@ -204,7 +213,7 @@ describe('Newsletter Subscriptions', () => {
test('unsubscribe via email link while logged in', async () => {
// Mock window.location
Object.defineProperty(window, 'location', {
- value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
+ value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}&key=hashedMemberUuid`),
writable: true
});
@@ -216,13 +225,18 @@ describe('Newsletter Subscriptions', () => {
// Verify the API was hit to collect subscribed newsletters
expect(ghostApi.member.newsletters).toHaveBeenLastCalledWith(
- {uuid: FixtureMember.subbedToNewsletter.uuid}
+ {
+ uuid: FixtureMember.subbedToNewsletter.uuid,
+ key: 'hashedMemberUuid'
+ }
);
// Verify the local state shows the newsletter as unsubscribed
let newsletterToggles = within(popupIframeDocument).queryAllByTestId('checkmark-container');
let newsletter1Toggle = newsletterToggles[0];
let newsletter2Toggle = newsletterToggles[1];
+ expect(within(popupIframeDocument).getByText(/will no longer receive/)).toBeInTheDocument();
+
expect(newsletter1Toggle).toBeInTheDocument();
expect(newsletter2Toggle).toBeInTheDocument();
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
@@ -253,5 +267,26 @@ describe('Newsletter Subscriptions', () => {
expect(newsletter1Toggle).not.toHaveClass('gh-portal-toggle-checked');
expect(newsletter2Toggle).toHaveClass('gh-portal-toggle-checked');
});
+
+ test('unsubscribe link without a key param', async () => {
+ // Mock window.location
+ Object.defineProperty(window, 'location', {
+ value: new URL(`https://portal.localhost/?action=unsubscribe&uuid=${FixtureMember.subbedToNewsletter.uuid}&newsletter=${Newsletters[0].uuid}`),
+ writable: true
+ });
+
+ const {ghostApi, popupFrame, popupIframeDocument} = await setup({
+ site: FixtureSite.singleTier.onlyFreePlanWithoutStripe,
+ member: FixtureMember.subbedToNewsletter,
+ newsletters: Newsletters
+ }, true);
+
+ // Verify the popup frame is not shown
+ expect(popupFrame).toBeInTheDocument();
+ // Verify the API was hit to collect subscribed newsletters
+ expect(ghostApi.member.newsletters).not.toHaveBeenCalled();
+ // expect sign in page
+ expect(within(popupIframeDocument).queryByText('Sign in')).toBeInTheDocument();
+ });
});
});
diff --git a/apps/portal/src/tests/FeedbackFlow.test.js b/apps/portal/src/tests/FeedbackFlow.test.js
new file mode 100644
index 0000000000..14d231d22a
--- /dev/null
+++ b/apps/portal/src/tests/FeedbackFlow.test.js
@@ -0,0 +1,181 @@
+import App from '../App.js';
+import {appRender, fireEvent, waitFor, within} from '../utils/test-utils';
+import setupGhostApi from '../utils/api.js';
+import {getMemberData, getPostsData, getSiteData} from '../utils/fixtures-generator.js';
+
+const siteData = getSiteData();
+const memberData = getMemberData();
+const posts = getPostsData();
+const postSlug = posts[0].slug;
+const postId = posts[0].id;
+
+const setup = async (site = siteData, member = memberData, loggedOut = false, api = {}) => {
+ const ghostApi = setupGhostApi({siteUrl: site.url});
+ ghostApi.init = api?.init || jest.fn(() => {
+ return Promise.resolve({
+ site,
+ member: loggedOut ? null : member
+ });
+ });
+ ghostApi.feedback.add = api?.add || jest.fn(() => {
+ return Promise.resolve({
+ feedback: [
+ {
+ id: 1,
+ postId: 1,
+ memberId: member ? member.uuid : null,
+ score: 1
+ }
+ ]
+ });
+ });
+
+ const utils = appRender(
+
+ );
+
+ // Note: this await is CRITICAL otherwise the iframe won't be loaded
+ const popupFrame = await utils.findByTitle(/portal-popup/i);
+ const popupIframeDocument = popupFrame.contentDocument;
+
+ return {
+ ghostApi,
+ popupIframeDocument,
+ popupFrame,
+ ...utils
+ };
+};
+
+describe('Feedback Submission Flow', () => {
+ describe('Valid feedback URL', () => {
+ describe('Logged in', () => {
+ test('Autosubmits feedback', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}&key=key`),
+ writable: true
+ });
+
+ const {ghostApi, popupFrame, popupIframeDocument} = await setup();
+
+ expect(popupFrame).toBeInTheDocument();
+ expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
+
+ within(popupIframeDocument).getByText('Thanks for the feedback!');
+ within(popupIframeDocument).getByText('Your input helps shape what gets published.');
+ });
+
+ test('Autosubmits feedback w/o uuid or key params', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/`),
+ writable: true
+ });
+ const {ghostApi, popupFrame, popupIframeDocument} = await setup();
+
+ expect(popupFrame).toBeInTheDocument();
+ expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
+ within(popupIframeDocument).getByText('Thanks for the feedback!');
+ within(popupIframeDocument).getByText('Your input helps shape what gets published.');
+ });
+ });
+
+ describe('Logged out', () => {
+ test('Requires confirmation', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}&key=key`),
+ writable: true
+ });
+ const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, null, true);
+
+ expect(popupFrame).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText('Give feedback on this post')).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText('More like this')).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText('Less like this')).toBeInTheDocument();
+ expect(ghostApi.feedback.add).toHaveBeenCalledTimes(0);
+
+ const submitBtn = within(popupIframeDocument).getByText('Submit feedback');
+ fireEvent.click(submitBtn);
+
+ expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
+
+ // the re-render loop is slow to get to the final state
+ await waitFor(() => {
+ within(popupIframeDocument).getByText('Thanks for the feedback!');
+ within(popupIframeDocument).getByText('Your input helps shape what gets published.');
+ });
+ });
+
+ test('Requires login without key', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}`),
+ writable: true
+ });
+ const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, null, true);
+
+ expect(popupFrame).toBeInTheDocument();
+ expect(ghostApi.feedback.add).toHaveBeenCalledTimes(0);
+ expect(within(popupIframeDocument).getByText(/Sign in/)).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText(/Sign up/)).toBeInTheDocument();
+ });
+
+ test('Requires login without uuid or key', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/`),
+ writable: true
+ });
+ const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, null, true);
+
+ expect(popupFrame).toBeInTheDocument();
+ expect(ghostApi.feedback.add).toHaveBeenCalledTimes(0);
+ expect(within(popupIframeDocument).getByText(/Sign in/)).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText(/Sign up/)).toBeInTheDocument();
+ });
+ });
+
+ test('Error on fail to submit', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/${postSlug}/#/feedback/${postId}/1/?uuid=${memberData.uuid}&key=key`),
+ writable: true
+ });
+ const mockApi = {
+ add: jest.fn(() => {
+ return Promise.reject(new Error('Failed to submit feedback'));
+ })
+ };
+ const {ghostApi, popupFrame, popupIframeDocument} = await setup(siteData, memberData, false, mockApi);
+
+ expect(popupFrame).toBeInTheDocument();
+ expect(ghostApi.feedback.add).toHaveBeenCalledTimes(1);
+ expect(within(popupIframeDocument).getByText(/Sorry/)).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText(/There was a problem submitting your feedback/)).toBeInTheDocument();
+ });
+ });
+
+ describe('Invalid feedback URL', () => {
+ test('Redirects logged in members to account settings', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/postslughere/#/feedback/1/1/1/`),
+ writable: true
+ });
+ const {popupFrame, popupIframeDocument} = await setup();
+
+ expect(popupFrame).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText(/Your account/)).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText(/Sign out/)).toBeInTheDocument();
+ });
+
+ test('Redirects logged out users to sign up', async () => {
+ Object.defineProperty(window, 'location', {
+ value: new URL(`${siteData.url}/postslughere/#/feedback/1/1/1/`),
+ writable: true
+ });
+ const {popupFrame, popupIframeDocument} = await setup(siteData, null, true);
+
+ expect(popupFrame).toBeInTheDocument();
+ // takes to sign up
+ await waitFor(() => {
+ expect(within(popupIframeDocument).getByText(/Name/)).toBeInTheDocument();
+ expect(within(popupIframeDocument).getByText(/Email/)).toBeInTheDocument();
+ });
+ });
+ });
+});
diff --git a/apps/portal/src/utils/api.js b/apps/portal/src/utils/api.js
index 5ecc72430b..1fe06f485e 100644
--- a/apps/portal/src/utils/api.js
+++ b/apps/portal/src/utils/api.js
@@ -134,10 +134,11 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
};
api.feedback = {
- async add({uuid, postId, score}) {
+ async add({uuid, key, postId, score}) {
let url = endpointFor({type: 'members', resource: 'feedback'});
- url = url + `?uuid=${uuid}`;
-
+ if (uuid && key) { // only necessary if not logged in, and both are required if so
+ url = url + `?uuid=${uuid}&key=${key}`;
+ }
const body = {
feedback: [
{
@@ -303,9 +304,9 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
});
},
- async newsletters({uuid}) {
+ async newsletters({uuid, key}) {
let url = endpointFor({type: 'members', resource: `member/newsletters`});
- url = url + `?uuid=${uuid}`;
+ url = url + `?uuid=${uuid}&key=${key}`;
return makeRequest({
url,
credentials: 'same-origin'
@@ -317,9 +318,9 @@ function setupGhostApi({siteUrl = window.location.origin, apiUrl, apiKey}) {
});
},
- async updateNewsletters({uuid, newsletters, enableCommentNotifications}) {
+ async updateNewsletters({uuid, newsletters, key, enableCommentNotifications}) {
let url = endpointFor({type: 'members', resource: `member/newsletters`});
- url = url + `?uuid=${uuid}`;
+ url = url + `?uuid=${uuid}&key=${key}`;
const body = {
newsletters
};
diff --git a/apps/portal/src/utils/fixtures-generator.js b/apps/portal/src/utils/fixtures-generator.js
index 74adfd1369..d87f8fa530 100644
--- a/apps/portal/src/utils/fixtures-generator.js
+++ b/apps/portal/src/utils/fixtures-generator.js
@@ -41,6 +41,7 @@ export function getSiteData({
membersSupportAddress: members_support_address = 'support@example.com',
editorDefaultEmailRecipients: editor_default_email_recipients = 'visibility',
newsletters = [],
+ posts = getPostsData(),
commentsEnabled,
recommendations = [],
recommendationsEnabled
@@ -71,7 +72,8 @@ export function getSiteData({
newsletters,
recommendations,
recommendations_enabled: !!recommendationsEnabled,
- editor_default_email_recipients
+ editor_default_email_recipients,
+ posts
};
}
@@ -175,6 +177,33 @@ export function getNewslettersData({numOfNewsletters = 3} = {}) {
return newsletters.slice(0, numOfNewsletters);
}
+export function getPostsData({numOfPosts = 3} = {}) {
+ const posts = [];
+ for (let i = 0; i < numOfPosts; i++) {
+ posts.push(getPostData({
+ title: `Post ${i + 1}`,
+ slug: `post-${i + 1}`
+ }));
+ }
+ return posts.slice(0, numOfPosts);
+}
+
+export function getPostData({
+ id = `post_${objectId()}`,
+ title = 'Post',
+ excerpt = 'Post excerpt',
+ slug = 'post',
+ featured = false
+} = {}) {
+ return {
+ id,
+ title,
+ excerpt,
+ slug,
+ featured
+ };
+}
+
export function getProductsData({numOfProducts = 3} = {}) {
const products = [
getProductData({
diff --git a/apps/portal/src/utils/fixtures.js b/apps/portal/src/utils/fixtures.js
index 16ebb82739..baf43cd66c 100644
--- a/apps/portal/src/utils/fixtures.js
+++ b/apps/portal/src/utils/fixtures.js
@@ -121,6 +121,29 @@ export const site = getSiteData({
subscribe_on_signup: false,
paid: false
}
+ ],
+ posts: [
+ {
+ id: 'post_66aacfe061c94e10eb6e4fc1',
+ title: 'Post 1',
+ excerpt: 'Post excerpt',
+ slug: 'post-1',
+ featured: false
+ },
+ {
+ id: 'post_66aacfe04f14b8dbb56c5721',
+ title: 'Post 2',
+ excerpt: 'Post excerpt',
+ slug: 'post-2',
+ featured: false
+ },
+ {
+ id: 'post_66aacfe03d609460819af18c',
+ title: 'Post 3',
+ excerpt: 'Post excerpt',
+ slug: 'post-3',
+ featured: false
+ }
]
});
diff --git a/ghost/audience-feedback/lib/AudienceFeedbackService.js b/ghost/audience-feedback/lib/AudienceFeedbackService.js
index cb262ee23a..f72cc0aa78 100644
--- a/ghost/audience-feedback/lib/AudienceFeedbackService.js
+++ b/ghost/audience-feedback/lib/AudienceFeedbackService.js
@@ -17,15 +17,16 @@ class AudienceFeedbackService {
* @param {string} uuid
* @param {string} postId
* @param {0 | 1} score
+ * @param {string} key - hashed uuid value
*/
- buildLink(uuid, postId, score) {
+ buildLink(uuid, postId, score, key) {
let postUrl = this.#urlService.getUrlByResourceId(postId, {absolute: true});
if (postUrl.match(/\/404\//)) {
postUrl = this.#baseURL;
}
const url = new URL(postUrl);
- url.hash = `#/feedback/${postId}/${score}/?uuid=${encodeURIComponent(uuid)}`;
+ url.hash = `#/feedback/${postId}/${score}/?uuid=${encodeURIComponent(uuid)}&key=${encodeURIComponent(key)}`;
return url;
}
}
diff --git a/ghost/audience-feedback/test/AudienceFeedbackService.test.js b/ghost/audience-feedback/test/AudienceFeedbackService.test.js
index 8a85d54262..c744f510d6 100644
--- a/ghost/audience-feedback/test/AudienceFeedbackService.test.js
+++ b/ghost/audience-feedback/test/AudienceFeedbackService.test.js
@@ -10,7 +10,8 @@ describe('audienceFeedbackService', function () {
uuid: '7b11de3c-dff9-4563-82ae-a281122d201d',
postId: '634fc3901e0a291855d8b135',
postTitle: 'somepost',
- score: 1
+ score: 1,
+ key: 'somekey'
};
describe('build link', function () {
@@ -23,8 +24,8 @@ describe('audienceFeedbackService', function () {
baseURL: new URL('https://localhost:2368')
}
});
- const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score);
- const expectedLink = `https://localhost:2368/${mockData.postTitle}/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}`;
+ const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score, mockData.key);
+ const expectedLink = `https://localhost:2368/${mockData.postTitle}/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}&key=somekey`;
assert.equal(link.href, expectedLink);
});
@@ -37,8 +38,8 @@ describe('audienceFeedbackService', function () {
baseURL: new URL('https://localhost:2368')
}
});
- const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score);
- const expectedLink = `https://localhost:2368/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}`;
+ const link = instance.buildLink(mockData.uuid, mockData.postId, mockData.score, mockData.key);
+ const expectedLink = `https://localhost:2368/#/feedback/${mockData.postId}/${mockData.score}/?uuid=${mockData.uuid}&key=somekey`;
assert.equal(link.href, expectedLink);
});
});
diff --git a/ghost/core/core/frontend/services/routing/controllers/unsubscribe.js b/ghost/core/core/frontend/services/routing/controllers/unsubscribe.js
index 7b730ade70..2fee46760a 100644
--- a/ghost/core/core/frontend/services/routing/controllers/unsubscribe.js
+++ b/ghost/core/core/frontend/services/routing/controllers/unsubscribe.js
@@ -3,6 +3,8 @@ const url = require('url');
const members = require('../../../../server/services/members');
const urlUtils = require('../../../../shared/url-utils');
const logging = require('@tryghost/logging');
+const settingsHelpers = require('../../../../server/services/settings-helpers');
+const crypto = require('crypto');
module.exports = async function unsubscribeController(req, res) {
debug('unsubscribeController');
@@ -19,6 +21,17 @@ module.exports = async function unsubscribeController(req, res) {
// Do an actual unsubscribe
try {
+ if (!query.key) {
+ logging.warn('[List-Unsubscribe] Unsubscribe failed due to missing verification key for ' + query.uuid);
+ return res.status(400).end();
+ }
+ const membersKey = settingsHelpers.getMembersValidationKey();
+ const memberHmac = crypto.createHmac('sha256', membersKey).update(query.uuid).digest('hex');
+ if (memberHmac !== query.key) {
+ logging.warn('[List-Unsubscribe] Unsubscribe failed due to invalid key for ' + query.uuid);
+ return res.status(400).end();
+ }
+
const member = await members.api.members.get({uuid: query.uuid}, {withRelated: ['newsletters']});
if (member) {
if (query.comments) {
@@ -60,6 +73,9 @@ module.exports = async function unsubscribeController(req, res) {
if (query.comments) {
redirectUrl.searchParams.append('comments', query.comments);
}
+ if (query.key) {
+ redirectUrl.searchParams.append('key', query.key);
+ }
redirectUrl.searchParams.append('action', 'unsubscribe');
return res.redirect(302, redirectUrl.href);
diff --git a/ghost/core/core/server/services/members/middleware.js b/ghost/core/core/server/services/members/middleware.js
index e73bfd65f4..2c4278b0a9 100644
--- a/ghost/core/core/server/services/members/middleware.js
+++ b/ghost/core/core/server/services/members/middleware.js
@@ -15,10 +15,12 @@ const tpl = require('@tryghost/tpl');
const onHeaders = require('on-headers');
const tiersService = require('../tiers/service');
const config = require('../../../shared/config');
+const settingsHelpers = require('../settings-helpers');
const messages = {
missingUuid: 'Missing uuid.',
- invalidUuid: 'Invalid uuid.'
+ invalidUuid: 'Invalid uuid.',
+ invalidKey: 'Invalid key.'
};
const getFreeTier = async function getFreeTier() {
@@ -97,7 +99,7 @@ const loadMemberSession = async function loadMemberSession(req, res, next) {
};
/**
- * Require member authentication, and make it possible to authenticate via uuid.
+ * Require member authentication, and make it possible to authenticate via uuid + hashed key.
* You can chain this after loadMemberSession to make it possible to authenticate via both the uuid and the session.
*/
const authMemberByUuid = async function authMemberByUuid(req, res, next) {
@@ -114,6 +116,22 @@ const authMemberByUuid = async function authMemberByUuid(req, res, next) {
});
}
+ const key = req.query.key;
+ if (!key) {
+ throw new errors.UnauthorizedError({
+ message: tpl(messages.invalidKey)
+ });
+ }
+
+ // the request key is a hashed value from the member uuid and the members validation key so we can verify the source
+ // (only Ghost should be able to generate the key)
+ const memberHmac = crypto.createHmac('sha256', settingsHelpers.getMembersValidationKey()).update(uuid).digest('hex');
+ if (memberHmac !== key) {
+ throw new errors.UnauthorizedError({
+ message: tpl(messages.invalidKey)
+ });
+ }
+
const member = await membersService.api.memberBREADService.read({uuid});
if (!member) {
throw new errors.UnauthorizedError({
@@ -193,25 +211,14 @@ const deleteSuppression = async function deleteSuppression(req, res) {
const getMemberNewsletters = async function getMemberNewsletters(req, res) {
try {
- const memberUuid = req.query.uuid;
-
- if (!memberUuid) {
- res.writeHead(400);
- return res.end('Invalid member uuid');
- }
-
- const memberData = await membersService.api.members.get({
- uuid: memberUuid
- }, {
- withRelated: ['newsletters']
- });
+ const memberData = req.member; // validation assumed
if (!memberData) {
res.writeHead(404);
return res.end('Email address not found.');
}
- const data = _.pick(memberData.toJSON(), 'uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status');
+ const data = _.pick(memberData, 'uuid', 'email', 'name', 'newsletters', 'enable_comment_notifications', 'status');
if (data.newsletters) {
data.newsletters = formatNewsletterResponse(data.newsletters);
@@ -226,23 +233,15 @@ const getMemberNewsletters = async function getMemberNewsletters(req, res) {
const updateMemberNewsletters = async function updateMemberNewsletters(req, res) {
try {
- const memberUuid = req.query.uuid;
- if (!memberUuid) {
- res.writeHead(400);
- return res.end('Invalid member uuid');
- }
-
- const data = _.pick(req.body, 'newsletters', 'enable_comment_notifications');
- const memberData = await membersService.api.members.get({
- uuid: memberUuid
- });
+ const memberData = req.member; // validation assumed
if (!memberData) {
res.writeHead(404);
return res.end('Email address not found.');
}
+ const data = _.pick(req.body, 'newsletters', 'enable_comment_notifications');
const options = {
- id: memberData.get('id'),
+ id: memberData.id,
withRelated: ['newsletters']
};
diff --git a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js
index bb59ba98c7..f11760976c 100644
--- a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js
+++ b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js
@@ -106,6 +106,16 @@ class SettingsHelpers {
return domain;
}
+ /**
+ * Retrieves the member validation key from the settings cache. The intent is for this key to be used where member
+ * auth is not required. For example, unsubscribe links in emails, which are required to be one-click unsubscribe.
+ *
+ * @returns {string} The member validation key.
+ */
+ getMembersValidationKey() {
+ return this.settingsCache.get('members_email_auth_secret');
+ }
+
getMembersSupportAddress() {
let supportAddress = this.settingsCache.get('members_support_address');
diff --git a/ghost/core/core/server/web/members/app.js b/ghost/core/core/server/web/members/app.js
index 9a3e8435b8..63552ec82f 100644
--- a/ghost/core/core/server/web/members/app.js
+++ b/ghost/core/core/server/web/members/app.js
@@ -40,9 +40,16 @@ module.exports = function setupMembersApp() {
// Initializes members specific routes as well as assigns members specific data to the req/res objects
// We don't want to add global bodyParser middleware as that interferes with stripe webhook requests on - `/webhooks`.
- // Manage newsletter subscription via unsubscribe link
- membersApp.get('/api/member/newsletters', middleware.getMemberNewsletters);
- membersApp.put('/api/member/newsletters', bodyParser.json({limit: '50mb'}), middleware.updateMemberNewsletters);
+ // Manage newsletter subscription via unsubscribe link - these should be authenticated by uuid and hashed key
+ membersApp.get('/api/member/newsletters',
+ middleware.authMemberByUuid,
+ middleware.getMemberNewsletters
+ );
+ membersApp.put('/api/member/newsletters',
+ bodyParser.json({limit: '50mb'}),
+ middleware.authMemberByUuid,
+ middleware.updateMemberNewsletters
+ );
// Get and update member data
// Caching members content is an experimental feature
diff --git a/ghost/core/core/shared/config/defaults.json b/ghost/core/core/shared/config/defaults.json
index 006a350acc..cbbe2c3142 100644
--- a/ghost/core/core/shared/config/defaults.json
+++ b/ghost/core/core/shared/config/defaults.json
@@ -182,7 +182,7 @@
},
"portal": {
"url": "https://cdn.jsdelivr.net/ghost/portal@~{version}/umd/portal.min.js",
- "version": "2.38"
+ "version": "2.39"
},
"sodoSearch": {
"url": "https://cdn.jsdelivr.net/ghost/sodo-search@~{version}/umd/sodo-search.min.js",
diff --git a/ghost/core/core/shared/config/env/config.development.json b/ghost/core/core/shared/config/env/config.development.json
index d9551493f5..662af14c91 100644
--- a/ghost/core/core/shared/config/env/config.development.json
+++ b/ghost/core/core/shared/config/env/config.development.json
@@ -35,4 +35,4 @@
"maxAge": 0
}
}
-}
+}
\ No newline at end of file
diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap
index ab36fdd179..9d567e3baf 100644
--- a/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap
+++ b/ghost/core/test/e2e-api/admin/__snapshots__/email-previews.test.js.snap
@@ -605,7 +605,7 @@ table.body h2 span {
-
+
@@ -749,7 +749,7 @@ Another email card with a similar replacement, Jamie
-Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
+Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
@@ -778,7 +778,7 @@ exports[`Email Preview API Read can read post email preview with email card and
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": "24595",
+ "content-length": "24733",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@@ -1302,7 +1302,7 @@ table.body h2 span {
-
+
@@ -1463,7 +1463,7 @@ Header Level 3
-Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
+Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
@@ -1492,7 +1492,7 @@ exports[`Email Preview API Read can read post email preview with fields 4: [head
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": "29375",
+ "content-length": "29513",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@@ -2042,7 +2042,7 @@ table.body h2 span {
-
+
@@ -2180,7 +2180,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes '
-Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
+Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
@@ -2222,7 +2222,7 @@ exports[`Email Preview API Read has custom content transformations for email com
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": "24349",
+ "content-length": "24487",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@@ -3108,7 +3108,7 @@ table.body h2 span {
-
+
@@ -3253,7 +3253,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes '
-Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
+Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
@@ -3295,7 +3295,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter
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": "25132",
+ "content-length": "25270",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@@ -4207,7 +4207,7 @@ table.body h2 span {
-
+
@@ -4352,7 +4352,7 @@ Testing links [https://ghost.org/] in email excerpt and apostrophes '
-Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=requested-newsletter-uuid]
+Ghost © 2024 – Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=requested-newsletter-uuid]
@@ -4394,7 +4394,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 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": "25132",
+ "content-length": "25270",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/emails.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/emails.test.js.snap
index c6f86209e5..1702fc2649 100644
--- a/ghost/core/test/e2e-api/admin/__snapshots__/emails.test.js.snap
+++ b/ghost/core/test/e2e-api/admin/__snapshots__/emails.test.js.snap
@@ -790,12 +790,12 @@ Object {
"failed_count": 0,
"feedback_enabled": false,
"from": "support@example.com",
- "html": "Hey Jamie, Hey Jamie, Unsubscribe",
+ "html": "Hey Jamie, Hey Jamie, Unsubscribe",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"newsletter_id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"opened_count": 0,
"plaintext": "Hey Jamie, Hey Jamie
-Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&newsletter=preview]",
+Unsubscribe [http://127.0.0.1:2369/unsubscribe/?uuid=example-uuid&key=803e513e2d8b88e759d8f433779659af335d7308b4cbac809600d563f6b49a76&newsletter=preview]",
"post_id": "618ba1ffbe2896088840a6e3",
"recipient_filter": "all",
"reply_to": null,
@@ -817,7 +817,7 @@ exports[`Emails API Does default replacements on the HTML body of an old email 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": "923",
+ "content-length": "1061",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
diff --git a/ghost/core/test/e2e-api/admin/email-previews.test.js b/ghost/core/test/e2e-api/admin/email-previews.test.js
index a3d66da487..1b5567d4ee 100644
--- a/ghost/core/test/e2e-api/admin/email-previews.test.js
+++ b/ghost/core/test/e2e-api/admin/email-previews.test.js
@@ -4,6 +4,7 @@ const assert = require('assert/strict');
const sinon = require('sinon');
const escapeRegExp = require('lodash/escapeRegExp');
const should = require('should');
+const settingsHelpers = require('../../../core/server/services/settings-helpers');
// @TODO: factor out these requires
const ObjectId = require('bson-objectid').default;
@@ -37,6 +38,7 @@ describe('Email Preview API', function () {
beforeEach(function () {
mockManager.mockMailgun();
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test-validation-key');
});
before(async function () {
diff --git a/ghost/core/test/e2e-api/admin/emails.test.js b/ghost/core/test/e2e-api/admin/emails.test.js
index 7f49929930..31b24b1edf 100644
--- a/ghost/core/test/e2e-api/admin/emails.test.js
+++ b/ghost/core/test/e2e-api/admin/emails.test.js
@@ -4,6 +4,7 @@ const assert = require('assert/strict');
const sinon = require('sinon');
const jobManager = require('../../../core/server/services/jobs/job-service');
const models = require('../../../core/server/models');
+const settingsHelpers = require('../../../core/server/services/settings-helpers');
const matchEmail = {
id: anyObjectId,
@@ -43,6 +44,7 @@ describe('Emails API', function () {
beforeEach(function () {
mockManager.mockEvents();
mockManager.mockMailgun();
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test-validation-key');
});
afterEach(function () {
diff --git a/ghost/core/test/e2e-api/members/__snapshots__/feedback.test.js.snap b/ghost/core/test/e2e-api/members/__snapshots__/feedback.test.js.snap
index d9487d896b..997655f492 100644
--- a/ghost/core/test/e2e-api/members/__snapshots__/feedback.test.js.snap
+++ b/ghost/core/test/e2e-api/members/__snapshots__/feedback.test.js.snap
@@ -26,6 +26,104 @@ Object {
}
`;
+exports[`Members Feedback Authentication Allows authentication via uuid (+ key) 1: [body] 1`] = `
+Object {
+ "feedback": Array [
+ Object {
+ "id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "memberId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "postId": StringMatching /\\[a-f0-9\\]\\{24\\}/,
+ "score": 1,
+ },
+ ],
+}
+`;
+
+exports[`Members Feedback Authentication Allows authentication via uuid (+ key) 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": "132",
+ "content-type": "application/json; charset=utf-8",
+ "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
+ "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/feedback\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
+ "vary": "Accept-Encoding",
+ "x-powered-by": "Express",
+}
+`;
+
+exports[`Members Feedback Authentication Thorws for invalid key 1: [body] 1`] = `
+Object {
+ "errors": Array [
+ Object {
+ "code": null,
+ "context": null,
+ "details": null,
+ "ghostErrorCode": null,
+ "help": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
+ "message": "Invalid key.",
+ "property": null,
+ "type": "UnauthorizedError",
+ },
+ ],
+}
+`;
+
+exports[`Members Feedback Authentication Throws for invalid uuid 1: [body] 1`] = `
+Object {
+ "errors": Array [
+ Object {
+ "code": null,
+ "context": null,
+ "details": null,
+ "ghostErrorCode": null,
+ "help": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
+ "message": "Invalid key.",
+ "property": null,
+ "type": "UnauthorizedError",
+ },
+ ],
+}
+`;
+
+exports[`Members Feedback Authentication Throws for missing key 1: [body] 1`] = `
+Object {
+ "errors": Array [
+ Object {
+ "code": null,
+ "context": null,
+ "details": null,
+ "ghostErrorCode": null,
+ "help": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
+ "message": "Invalid key.",
+ "property": null,
+ "type": "UnauthorizedError",
+ },
+ ],
+}
+`;
+
+exports[`Members Feedback Authentication Throws for nonexisting uuid 1: [body] 1`] = `
+Object {
+ "errors": Array [
+ Object {
+ "code": null,
+ "context": null,
+ "details": null,
+ "ghostErrorCode": null,
+ "help": null,
+ "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/,
+ "message": "Invalid key.",
+ "property": null,
+ "type": "UnauthorizedError",
+ },
+ ],
+}
+`;
+
exports[`Members Feedback Can add feedback 1: [body] 1`] = `
Object {
"feedback": Array [
diff --git a/ghost/core/test/e2e-api/members/feedback.test.js b/ghost/core/test/e2e-api/members/feedback.test.js
index 490db7954e..f98908325c 100644
--- a/ghost/core/test/e2e-api/members/feedback.test.js
+++ b/ghost/core/test/e2e-api/members/feedback.test.js
@@ -3,9 +3,13 @@ const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = requ
const {anyEtag, anyObjectId, anyLocationFor, anyErrorId} = matchers;
const models = require('../../../core/server/models');
const sinon = require('sinon');
+const settingsHelpers = require('../../../core/server/services/settings-helpers');
+const crypto = require('crypto');
+
+const membersValidationKeyMock = 'abc123dontstealme';
describe('Members Feedback', function () {
- let membersAgent, membersAgent2, memberUuid;
+ let membersAgent, membersAgent2, memberUuid, memberHmac;
let clock;
before(async function () {
@@ -14,9 +18,11 @@ describe('Members Feedback', function () {
await fixtureManager.init('posts', 'members');
memberUuid = fixtureManager.get('members', 0).uuid;
+ memberHmac = crypto.createHmac('sha256', membersValidationKeyMock).update(memberUuid).digest('hex');
});
beforeEach(function () {
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns(membersValidationKeyMock);
mockManager.mockMail();
});
@@ -55,21 +61,45 @@ describe('Members Feedback', function () {
]
});
});
- });
- describe('Validation', function () {
- const postId = fixtureManager.get('posts', 0).id;
+ it('Allows authentication via uuid (+ key)', async function () {
+ const postId = fixtureManager.get('posts', 0).id;
- it('Throws for invalid score', async function () {
+ await membersAgent
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
+ .body({
+ feedback: [{
+ score: 1,
+ post_id: postId
+ }]
+ })
+ .expectStatus(201)
+ .matchHeaderSnapshot({
+ etag: anyEtag,
+ location: anyLocationFor('feedback')
+ })
+ .matchBodySnapshot({
+ feedback: [
+ {
+ id: anyObjectId,
+ memberId: anyObjectId,
+ postId: anyObjectId
+ }
+ ]
+ });
+ });
+
+ it('Throws for missing key', async function () {
+ const postId = fixtureManager.get('posts', 0).id;
await membersAgent
.post(`/api/feedback/?uuid=${memberUuid}`)
.body({
feedback: [{
- score: 2,
+ score: 1,
post_id: postId
}]
})
- .expectStatus(422)
+ .expectStatus(401)
.matchBodySnapshot({
errors: [
{
@@ -79,16 +109,17 @@ describe('Members Feedback', function () {
});
});
- it('Throws for invalid score type', async function () {
+ it('Thorws for invalid key', async function () {
+ const postId = fixtureManager.get('posts', 0).id;
await membersAgent
- .post(`/api/feedback/?uuid=${memberUuid}`)
+ .post(`/api/feedback/?uuid=${memberUuid}&key=1234`)
.body({
feedback: [{
- score: 'text',
+ score: 1,
post_id: postId
}]
})
- .expectStatus(422)
+ .expectStatus(401)
.matchBodySnapshot({
errors: [
{
@@ -99,6 +130,7 @@ describe('Members Feedback', function () {
});
it('Throws for invalid uuid', async function () {
+ const postId = fixtureManager.get('posts', 0).id;
await membersAgent
.post(`/api/feedback/?uuid=1234`)
.body({
@@ -118,6 +150,7 @@ describe('Members Feedback', function () {
});
it('Throws for nonexisting uuid', async function () {
+ const postId = fixtureManager.get('posts', 0).id;
const uuid = '00000000-0000-0000-0000-000000000000';
await membersAgent
.post(`/api/feedback/?uuid=${uuid}`)
@@ -136,10 +169,52 @@ describe('Members Feedback', function () {
]
});
});
+ });
+
+ describe('Validation', function () {
+ const postId = fixtureManager.get('posts', 0).id;
+
+ it('Throws for invalid score', async function () {
+ await membersAgent
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
+ .body({
+ feedback: [{
+ score: 2,
+ post_id: postId
+ }]
+ })
+ .expectStatus(422)
+ .matchBodySnapshot({
+ errors: [
+ {
+ id: anyErrorId
+ }
+ ]
+ });
+ });
+
+ it('Throws for invalid score type', async function () {
+ await membersAgent
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
+ .body({
+ feedback: [{
+ score: 'text',
+ post_id: postId
+ }]
+ })
+ .expectStatus(422)
+ .matchBodySnapshot({
+ errors: [
+ {
+ id: anyErrorId
+ }
+ ]
+ });
+ });
it('Throws for nonexisting post', async function () {
await membersAgent
- .post(`/api/feedback/?uuid=${memberUuid}`)
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
.body({
feedback: [{
score: 1,
@@ -161,7 +236,7 @@ describe('Members Feedback', function () {
const postId = fixtureManager.get('posts', 0).id;
await membersAgent
- .post(`/api/feedback/?uuid=${memberUuid}`)
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
.body({
feedback: [{
score: 1,
@@ -189,7 +264,7 @@ describe('Members Feedback', function () {
const postId = fixtureManager.get('posts', 1).id;
const {body} = await membersAgent
- .post(`/api/feedback/?uuid=${memberUuid}`)
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
.body({
feedback: [{
score: 0,
@@ -220,7 +295,7 @@ describe('Members Feedback', function () {
clock.tick(10 * 60 * 1000);
const {body: body2} = await membersAgent
- .post(`/api/feedback/?uuid=${memberUuid}`)
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
.body({
feedback: [{
score: 1,
@@ -252,7 +327,7 @@ describe('Members Feedback', function () {
// Do the same change again, and the model shouldn't change
const {body: body3} = await membersAgent
- .post(`/api/feedback/?uuid=${memberUuid}`)
+ .post(`/api/feedback/?uuid=${memberUuid}&key=${memberHmac}`)
.body({
feedback: [{
score: 1,
diff --git a/ghost/core/test/e2e-api/members/middleware.test.js b/ghost/core/test/e2e-api/members/middleware.test.js
index 7ce1e96bfb..88a0384933 100644
--- a/ghost/core/test/e2e-api/members/middleware.test.js
+++ b/ghost/core/test/e2e-api/members/middleware.test.js
@@ -3,6 +3,8 @@ const {agentProvider, mockManager, fixtureManager, matchers, configUtils} = requ
const {anyEtag, anyObjectId, anyUuid, anyISODateTime, stringMatching} = matchers;
const models = require('../../../core/server/models');
const should = require('should');
+const sinon = require('sinon');
+const settingsHelpers = require('../../../core/server/services/settings-helpers');
let membersAgent;
@@ -60,8 +62,11 @@ describe('Comments API', function () {
let member = await models.Member.findOne({id: fixtureManager.get('members', 0).id}, {require: true});
member.get('enable_comment_notifications').should.eql(true, 'This test requires the initial value to be true');
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const hmac = crypto.createHmac('sha256', 'test').update(member.get('uuid')).digest('hex');
+
await membersAgent
- .put(`/api/member/newsletters/?uuid=${member.get('uuid')}`)
+ .put(`/api/member/newsletters/?uuid=${member.get('uuid')}&key=${hmac}`)
.body({
enable_comment_notifications: false
})
@@ -179,9 +184,12 @@ describe('Comments API', function () {
member = await models.Member.findOne({id: member.id}, {require: true});
member.get('enable_comment_notifications').should.eql(false);
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const hmac = crypto.createHmac('sha256', 'test').update(member.get('uuid')).digest('hex');
+
// Via updateMemberNewsletters
await membersAgent
- .put(`/api/member/newsletters/?uuid=${member.get('uuid')}`)
+ .put(`/api/member/newsletters/?uuid=${member.get('uuid')}&key=${hmac}`)
.body({
enable_comment_notifications: true
})
diff --git a/ghost/core/test/e2e-frontend/members.test.js b/ghost/core/test/e2e-frontend/members.test.js
index 80ed4e0c29..92cdb17716 100644
--- a/ghost/core/test/e2e-frontend/members.test.js
+++ b/ghost/core/test/e2e-frontend/members.test.js
@@ -6,12 +6,14 @@ const moment = require('moment');
const testUtils = require('../utils');
const configUtils = require('../utils/configUtils');
const settingsCache = require('../../core/shared/settings-cache');
+const settingsHelpers = require('../../core/server/services/settings-helpers');
const DomainEvents = require('@tryghost/domain-events');
const {MemberPageViewEvent} = require('@tryghost/member-events');
const models = require('../../core/server/models');
const {fixtureManager} = require('../utils/e2e-framework');
const DataGenerator = require('../utils/fixtures/data-generator');
const members = require('../../core/server/services/members');
+const crypto = require('crypto');
function assertContentIsPresent(res) {
res.text.should.containEql('markdown');
@@ -168,88 +170,114 @@ describe('Front-end members behavior', function () {
.expect(400);
});
- it('should error for fetching member newsletters with missing uuid', async function () {
- await request.get('/members/api/member/newsletters')
- .expect(400);
- });
+ describe('Newsletters', function () {
+ afterEach(function () {
+ sinon.restore();
+ });
- it('should error for fetching member newsletters with invalid uuid', async function () {
- await request.get('/members/api/member/newsletters?uuid=abc')
- .expect(404);
- });
+ it('should error for fetching member newsletters with missing uuid', async function () {
+ await request.get('/members/api/member/newsletters')
+ .expect(401);
+ });
- it('should error for updating member newsletters with missing uuid', async function () {
- await request.put('/members/api/member/newsletters')
- .expect(400);
- });
+ it('should error for fetching member newsletters with invalid uuid', async function () {
+ await request.get('/members/api/member/newsletters?uuid=abc')
+ .expect(401);
+ });
- it('should error for updating member newsletters with invalid uuid', async function () {
- await request.put('/members/api/member/newsletters?uuid=abc')
- .expect(404);
- });
+ it('should error for updating member newsletters with missing uuid', async function () {
+ await request.put('/members/api/member/newsletters')
+ .expect(401);
+ });
- it('should fetch and update member newsletters with valid uuid', async function () {
- const memberUUID = DataGenerator.Content.members[0].uuid;
+ it('should error for updating member newsletters with invalid uuid', async function () {
+ await request.put('/members/api/member/newsletters?uuid=abc')
+ .expect(401);
+ });
- // Can fetch newsletter subscriptions
- const getRes = await request.get(`/members/api/member/newsletters?uuid=${memberUUID}`)
- .expect(200);
- const getJsonResponse = getRes.body;
+ it('should error for updating member newsletters with no key', async function () {
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ await request.get('/members/api/member/newsletters?uuid=abc')
+ .expect(401);
+ });
- should.exist(getJsonResponse);
- getJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
- getJsonResponse.should.not.have.property('id');
- getJsonResponse.newsletters.should.have.length(1);
+ it('should 401 for GET member newsletters with a mismatched hmac key', async function () {
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ await request.get('/members/api/member/newsletters?uuid=abc&key=blah')
+ .expect(401);
+ });
- // NOTE: these should be snapshots not code
- Object.keys(getJsonResponse.newsletters[0]).should.have.length(5);
- getJsonResponse.newsletters[0].should.have.properties([
- 'id',
- 'uuid',
- 'name',
- 'description',
- 'sort_order'
- ]);
+ it('should 401 for PUT member newsletters with a mismatched hmac key', async function () {
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ await request.put('/members/api/member/newsletters?uuid=abc&key=blah')
+ .expect(401);
+ });
- // Can update newsletter subscription
- const originalNewsletters = getJsonResponse.newsletters;
- const originalNewsletterName = originalNewsletters[0].name;
- originalNewsletters[0].name = 'cannot change me';
+ it('should fetch and update member newsletters with valid uuid', async function () {
+ const memberUUID = DataGenerator.Content.members[0].uuid;
- const res = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}`)
- .send({
- newsletters: []
- })
- .expect(200);
- const jsonResponse = res.body;
+ // Can fetch newsletter subscriptions
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const memberHmac = crypto.createHmac('sha256', 'test').update(memberUUID).digest('hex');
+ const getRes = await request.get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
+ .expect(200);
+ const getJsonResponse = getRes.body;
- should.exist(jsonResponse);
- jsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
- jsonResponse.should.not.have.property('id');
- jsonResponse.newsletters.should.have.length(0);
+ should.exist(getJsonResponse);
+ getJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
+ getJsonResponse.should.not.have.property('id');
+ getJsonResponse.newsletters.should.have.length(1);
- const resRestored = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}`)
- .send({
- newsletters: originalNewsletters
- })
- .expect(200);
+ // NOTE: these should be snapshots not code
+ Object.keys(getJsonResponse.newsletters[0]).should.have.length(5);
+ getJsonResponse.newsletters[0].should.have.properties([
+ 'id',
+ 'uuid',
+ 'name',
+ 'description',
+ 'sort_order'
+ ]);
- const restoreJsonResponse = resRestored.body;
- should.exist(restoreJsonResponse);
- restoreJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
- restoreJsonResponse.should.not.have.property('id');
- restoreJsonResponse.newsletters.should.have.length(1);
- // @NOTE: this seems like too much exposed information, needs a review
- Object.keys(restoreJsonResponse.newsletters[0]).should.have.length(5);
- restoreJsonResponse.newsletters[0].should.have.properties([
- 'id',
- 'uuid',
- 'name',
- 'description',
- 'sort_order'
- ]);
+ // Can update newsletter subscription
+ const originalNewsletters = getJsonResponse.newsletters;
+ const originalNewsletterName = originalNewsletters[0].name;
+ originalNewsletters[0].name = 'cannot change me';
- should.equal(restoreJsonResponse.newsletters[0].name, originalNewsletterName);
+ const res = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
+ .send({
+ newsletters: []
+ })
+ .expect(200);
+ const jsonResponse = res.body;
+
+ should.exist(jsonResponse);
+ jsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
+ jsonResponse.should.not.have.property('id');
+ jsonResponse.newsletters.should.have.length(0);
+
+ const resRestored = await request.put(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
+ .send({
+ newsletters: originalNewsletters
+ })
+ .expect(200);
+
+ const restoreJsonResponse = resRestored.body;
+ should.exist(restoreJsonResponse);
+ restoreJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']);
+ restoreJsonResponse.should.not.have.property('id');
+ restoreJsonResponse.newsletters.should.have.length(1);
+ // @NOTE: this seems like too much exposed information, needs a review
+ Object.keys(restoreJsonResponse.newsletters[0]).should.have.length(5);
+ restoreJsonResponse.newsletters[0].should.have.properties([
+ 'id',
+ 'uuid',
+ 'name',
+ 'description',
+ 'sort_order'
+ ]);
+
+ should.equal(restoreJsonResponse.newsletters[0].name, originalNewsletterName);
+ });
});
it('should serve theme 404 on members endpoint', async function () {
@@ -266,6 +294,10 @@ describe('Front-end members behavior', function () {
});
describe('Unsubscribe', function () {
+ afterEach(function () {
+ sinon.restore();
+ });
+
it('should redirect with uuid and action param', async function () {
await request.get('/unsubscribe/?uuid=XXX')
.expect(302)
@@ -278,12 +310,18 @@ describe('Front-end members behavior', function () {
.expect('Location', 'http://127.0.0.1:2369/?uuid=XXX&newsletter=YYY&action=unsubscribe');
});
+ it('should pass through an optional key param', async function () {
+ await request.get('/unsubscribe/?uuid=XXX&key=YYY')
+ .expect(302)
+ .expect('Location', 'http://127.0.0.1:2369/?uuid=XXX&key=YYY&action=unsubscribe');
+ });
+
it('should reject when missing a uuid', async function () {
await request.get('/unsubscribe/')
.expect(400);
});
- it('should do an actual unsubscribe on POST', async function () {
+ it('should return unauthorized with a bad key', async function () {
const newsletterId = fixtureManager.get('newsletters', 0).id;
const member = await createMember({
email: 'unsubscribe-member-test@example.com',
@@ -293,19 +331,40 @@ describe('Front-end members behavior', function () {
});
const memberUUID = member.get('uuid');
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('therealkey');
+ const memberHmac = crypto.createHmac('sha256','thefalsekey').update(memberUUID).digest('hex');
+
+ // auth via uuid+key should fail
+ await request
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
+ .expect(401);
+ });
+
+ it('should do an actual unsubscribe on POST', async function () {
+ const newsletterId = fixtureManager.get('newsletters', 0).id;
+ const member = await createMember({
+ email: 'unsubscribe-member-test-another@example.com',
+ newsletters: [
+ {id: newsletterId}
+ ]
+ });
+
+ const memberUUID = member.get('uuid');
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
// Can fetch newsletter subscriptions
let getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
- await request.post(`/unsubscribe/?uuid=${memberUUID}`)
+ await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}`)
.expect(201);
getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
getJsonResponse = getRes.body;
@@ -326,19 +385,21 @@ describe('Front-end members behavior', function () {
});
const memberUUID = member.get('uuid');
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
// Can fetch newsletter subscriptions
let getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 2);
- await request.post(`/unsubscribe/?uuid=${memberUUID}&newsletter=${newsletter2Uuid}`)
+ await request.post(`/unsubscribe/?uuid=${memberUUID}&newsletter=${newsletter2Uuid}&key=${memberHmac}`)
.expect(201);
getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
getJsonResponse = getRes.body;
@@ -357,15 +418,17 @@ describe('Front-end members behavior', function () {
});
const memberUUID = member.get('uuid');
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
// Can fetch newsletter subscriptions
let getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
- await request.post(`/unsubscribe/?uuid=${memberUUID}&comments=1`)
+ await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}&comments=1`)
.expect(201);
const updatedMember = await members.api.members.get({id: member.id}, {withRelated: ['newsletters']});
@@ -383,21 +446,23 @@ describe('Front-end members behavior', function () {
});
const memberUUID = member.get('uuid');
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
// Can fetch newsletter subscriptions
let getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
- await request.post(`/unsubscribe/?uuid=${memberUUID}`)
+ await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}`)
.type('form')
.send({'List-Unsubscribe': 'One-Click'})
.expect(201);
getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
getJsonResponse = getRes.body;
@@ -414,20 +479,22 @@ describe('Front-end members behavior', function () {
});
const memberUUID = member.get('uuid');
+ sinon.stub(settingsHelpers, 'getMembersValidationKey').returns('test');
+ const memberHmac = crypto.createHmac('sha256','test').update(memberUUID).digest('hex');
// Can fetch newsletter subscriptions
let getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
let getJsonResponse = getRes.body;
assert.equal(getJsonResponse.newsletters.length, 1);
- await request.post(`/unsubscribe/?uuid=${memberUUID}`)
+ await request.post(`/unsubscribe/?uuid=${memberUUID}&key=${memberHmac}`)
.field('List-Unsubscribe', 'One-Click')
.expect(201);
getRes = await request
- .get(`/members/api/member/newsletters?uuid=${memberUUID}`)
+ .get(`/members/api/member/newsletters?uuid=${memberUUID}&key=${memberHmac}`)
.expect(200);
getJsonResponse = getRes.body;
diff --git a/ghost/core/test/unit/server/services/members/middleware.test.js b/ghost/core/test/unit/server/services/members/middleware.test.js
index 94d0242984..e1e6570816 100644
--- a/ghost/core/test/unit/server/services/members/middleware.test.js
+++ b/ghost/core/test/unit/server/services/members/middleware.test.js
@@ -211,7 +211,8 @@ describe('Members Service Middleware', function () {
sinon.restore();
});
- it('returns 400 if no member uuid is part of the request', async function () {
+ // auth happens prior to this middleware
+ it('returns 404 if no member uuid is part of the request', async function () {
req.query = {};
// Call the middleware
@@ -219,11 +220,12 @@ describe('Members Service Middleware', function () {
// Check behavior
res.writeHead.calledOnce.should.be.true();
- res.writeHead.firstCall.args[0].should.eql(400);
+ res.writeHead.firstCall.args[0].should.eql(404);
res.end.calledOnce.should.be.true();
- res.end.firstCall.args[0].should.eql('Invalid member uuid');
+ res.end.firstCall.args[0].should.eql('Email address not found.');
});
+ // auth happens prior to this middleware
it('returns 404 if member uuid is not found', async function () {
req.query = {uuid: 'test'};
sinon.stub(membersService, 'api').get(() => {
@@ -246,8 +248,8 @@ describe('Members Service Middleware', function () {
it('attempts to update newsletters', async function () {
res.json = sinon.stub();
- req.query = {uuid: 'test'};
- const memberData = {
+ // member data appended if authed via uuid+key or session
+ req.member = {
id: 'test',
email: 'test@email.com',
name: 'Test Name',
@@ -258,10 +260,9 @@ describe('Members Service Middleware', function () {
sinon.stub(membersService, 'api').get(() => {
return {
members: {
- get: sinon.stub().resolves({id: 'test', email: 'test@email.com', get: () => 'test'}),
update: sinon.stub().resolves({
- ...memberData,
- toJSON: () => JSON.stringify(memberData)
+ ...req.member,
+ toJSON: () => JSON.stringify(req.member)
})
}
};
@@ -273,7 +274,22 @@ describe('Members Service Middleware', function () {
it('returns 400 on error', async function () {
// use a malformed request to trigger an error
- req = {};
+ // member data appended if authed via uuid+key or session
+ req.member = {
+ id: undefined,
+ email: 'test@email.com',
+ name: 'Test Name',
+ newsletters: [],
+ enable_comment_notifications: false,
+ status: 'free'
+ };
+ sinon.stub(membersService, 'api').get(() => {
+ return {
+ members: {
+ update: sinon.stub().rejects(new Error('Test Error'))
+ }
+ };
+ });
await membersMiddleware.updateMemberNewsletters(req, res);
// Check behavior
diff --git a/ghost/email-service/lib/EmailRenderer.js b/ghost/email-service/lib/EmailRenderer.js
index b36f80ad59..474b3d7c20 100644
--- a/ghost/email-service/lib/EmailRenderer.js
+++ b/ghost/email-service/lib/EmailRenderer.js
@@ -11,6 +11,7 @@ const tpl = require('@tryghost/tpl');
const cheerio = require('cheerio');
const {EmailAddressParser} = require('@tryghost/email-addresses');
const {registerHelpers} = require('./helpers/register-helpers');
+const crypto = require('crypto');
const messages = {
subscriptionStatus: {
@@ -117,7 +118,7 @@ class EmailRenderer {
/**
* @param {object} dependencies
* @param {object} dependencies.settingsCache
- * @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string}} dependencies.settingsHelpers
+ * @param {{getNoReplyAddress(): string, getMembersSupportAddress(): string, getMembersValidationKey(): string}} dependencies.settingsHelpers
* @param {object} dependencies.renderers
* @param {{render(object, options): string}} dependencies.renderers.lexical
* @param {{render(object, options): string}} dependencies.renderers.mobiledoc
@@ -501,9 +502,14 @@ class EmailRenderer {
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');
}
@@ -639,6 +645,12 @@ class EmailRenderer {
return member.uuid;
}
},
+ {
+ id: 'key',
+ getValue: (member) => {
+ return crypto.createHmac('sha256', this.#settingsHelpers.getMembersValidationKey()).update(member.uuid).digest('hex');
+ }
+ },
{
id: 'first_name',
getValue: (member) => {
@@ -962,13 +974,15 @@ class EmailRenderer {
const positiveLink = this.#audienceFeedbackService.buildLink(
'--uuid--',
post.id,
- 1
- ).href.replace('--uuid--', '%%{uuid}%%');
+ 1,
+ '--key--'
+ ).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%');
const negativeLink = this.#audienceFeedbackService.buildLink(
'--uuid--',
post.id,
- 0
- ).href.replace('--uuid--', '%%{uuid}%%');
+ 0,
+ '--key--'
+ ).href.replace('--uuid--', '%%{uuid}%%').replace('--key--', '%%{key}%%');
const commentUrl = new URL(postUrl);
commentUrl.hash = '#ghost-comments';
diff --git a/ghost/email-service/test/email-renderer.test.js b/ghost/email-service/test/email-renderer.test.js
index 7fa4f2f612..0dedfae005 100644
--- a/ghost/email-service/test/email-renderer.test.js
+++ b/ghost/email-service/test/email-renderer.test.js
@@ -6,6 +6,7 @@ const linkReplacer = require('@tryghost/link-replacer');
const sinon = require('sinon');
const logging = require('@tryghost/logging');
const {HtmlValidate} = require('html-validate');
+const crypto = require('crypto');
async function validateHtml(html) {
const htmlvalidate = new HtmlValidate({
@@ -60,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 getMembersValidationKey = () => {
+ return 'members-key';
+};
+
describe('Email renderer', function () {
let logStub;
@@ -92,7 +97,8 @@ describe('Email renderer', function () {
return 'UTC';
}
}
- }
+ },
+ settingsHelpers: {getMembersValidationKey}
});
newsletter = createModel({
uuid: 'newsletteruuid'
@@ -113,7 +119,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');
- assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`);
+ 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`);
});
it('returns a replacement if it is used', function () {
@@ -149,7 +156,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');
- assert.equal(replacements[0].getValue(member), `http://example.com/subdirectory/unsubscribe/?uuid=myuuid&newsletter=newsletteruuid`);
+ 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`);
});
it('returns correct name', function () {
@@ -310,6 +318,20 @@ describe('Email renderer', function () {
// In case of empty name
assert.equal(replacements[2].getValue({name: ''}), '');
});
+
+ it('handles members uuid and key', function () {
+ const html = '%%{uuid}%% %%{key}%%';
+ const replacements = emailRenderer.buildReplacementDefinitions({html, newsletterUuid: newsletter.get('uuid')});
+ assert.equal(replacements.length, 3);
+ assert.equal(replacements[0].token.toString(), '/%%\\{uuid\\}%%/g');
+ assert.equal(replacements[0].id, 'uuid');
+ assert.equal(replacements[0].getValue(member), 'myuuid');
+
+ assert.equal(replacements[1].token.toString(), '/%%\\{key\\}%%/g');
+ assert.equal(replacements[1].id, 'key');
+ const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update(member.uuid).digest('hex');
+ assert.equal(replacements[1].getValue(member), memberHmac);
+ });
});
describe('isMemberTrialing', function () {
@@ -958,8 +980,8 @@ describe('Email renderer', function () {
});
emailRenderer = new EmailRenderer({
audienceFeedbackService: {
- buildLink: (_uuid, _postId, score) => {
- return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid));
+ buildLink: (_uuid, _postId, score, key) => {
+ return new URL('http://feedback-link.com/?score=' + encodeURIComponent(score) + '&uuid=' + encodeURIComponent(_uuid) + '&key=' + encodeURIComponent(key));
}
},
urlUtils: {
@@ -1058,7 +1080,7 @@ describe('Email renderer', function () {
);
});
- it('returns feedback buttons and unsubcribe links', async function () {
+ it('returns feedback buttons and unsubscribe links', async function () {
const post = createModel(basePost);
const newsletter = createModel({
header_image: null,
@@ -1084,14 +1106,16 @@ describe('Email renderer', function () {
// Unsubscribe button included
response.plaintext.should.containEql('Unsubscribe [%%{unsubscribe_url}%%]');
response.html.should.containEql('Unsubscribe');
- response.replacements.length.should.eql(3);
+ response.replacements.length.should.eql(4);
response.replacements.should.match([
{
id: 'uuid'
},
{
- id: 'unsubscribe_url',
- token: /%%\{unsubscribe_url\}%%/g
+ id: 'key'
+ },
+ {
+ id: 'unsubscribe_url'
},
{
id: 'list_unsubscribe'
@@ -1440,21 +1464,23 @@ describe('Email renderer', function () {
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fencoded-link.com%2F%3Fcode%3Dtest%26source_tracking%3Dsite`,
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
'#',
- `http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
- `http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
- `http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
- `http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
+ `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
+ `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
+ `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
+ `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
`%%{unsubscribe_url}%%`,
`https://ghost.org/?via=pbg-newsletter&source_tracking=site`
]);
// Check uuid in replacements
- response.replacements.length.should.eql(3);
+ response.replacements.length.should.eql(4);
response.replacements[0].id.should.eql('uuid');
response.replacements[0].token.should.eql(/%%\{uuid\}%%/g);
- response.replacements[1].id.should.eql('unsubscribe_url');
- response.replacements[1].token.should.eql(/%%\{unsubscribe_url\}%%/g);
- response.replacements[2].id.should.eql('list_unsubscribe');
+ response.replacements[1].id.should.eql('key');
+ response.replacements[1].token.should.eql(/%%\{key\}%%/g);
+ response.replacements[2].id.should.eql('unsubscribe_url');
+ response.replacements[2].token.should.eql(/%%\{unsubscribe_url\}%%/g);
+ response.replacements[3].id.should.eql('list_unsubscribe');
});
it('replaces all relative links if click tracking is disabled', async function () {
@@ -1495,10 +1521,10 @@ describe('Email renderer', function () {
'http://example.com/',
'http://example.com/#relative-test',
'#',
- 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%',
- 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%',
- 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%',
- 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%',
+ 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%',
+ 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%',
+ 'http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%',
+ 'http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%',
'%%{unsubscribe_url}%%',
'https://ghost.org/?via=pbg-newsletter'
]);
@@ -1552,21 +1578,23 @@ describe('Email renderer', function () {
`http://tracked-link.com/?m=%%{uuid}%%&url=http%3A%2F%2Fexample.com%2F%3Fsource_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexternal-domain.com%2F%3Fref%3D123%26source_tracking%3Dsite`,
`http://tracked-link.com/?m=%%{uuid}%%&url=https%3A%2F%2Fexample.com%2F%3Fref%3D123%26source_tracking%3DTest%2BNewsletter%26post_tracking%3Dadded`,
- `http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
- `http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
- `http://feedback-link.com/?score=1&uuid=%%{uuid}%%`,
- `http://feedback-link.com/?score=0&uuid=%%{uuid}%%`,
+ `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
+ `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
+ `http://feedback-link.com/?score=1&uuid=%%{uuid}%%&key=%%{key}%%`,
+ `http://feedback-link.com/?score=0&uuid=%%{uuid}%%&key=%%{key}%%`,
`%%{unsubscribe_url}%%`,
`https://ghost.org/?via=pbg-newsletter&source_tracking=site`
]);
// Check uuid in replacements
- response.replacements.length.should.eql(3);
+ response.replacements.length.should.eql(4);
response.replacements[0].id.should.eql('uuid');
response.replacements[0].token.should.eql(/%%\{uuid\}%%/g);
- response.replacements[1].id.should.eql('unsubscribe_url');
- response.replacements[1].token.should.eql(/%%\{unsubscribe_url\}%%/g);
- response.replacements[2].id.should.eql('list_unsubscribe');
+ response.replacements[1].id.should.eql('key');
+ response.replacements[1].token.should.eql(/%%\{key\}%%/g);
+ response.replacements[2].id.should.eql('unsubscribe_url');
+ response.replacements[2].token.should.eql(/%%\{unsubscribe_url\}%%/g);
+ response.replacements[3].id.should.eql('list_unsubscribe');
});
it('removes data-gh-segment and renders paywall', async function () {
@@ -1643,14 +1671,16 @@ describe('Email renderer', function () {
response.html.should.containEql('Unsubscribe');
response.html.should.containEql('http://example.com');
- response.replacements.length.should.eql(3);
+ response.replacements.length.should.eql(4);
response.replacements.should.match([
{
id: 'uuid'
},
{
- id: 'unsubscribe_url',
- token: /%%\{unsubscribe_url\}%%/g
+ id: 'key'
+ },
+ {
+ id: 'unsubscribe_url'
},
{
id: 'list_unsubscribe'
@@ -2263,42 +2293,38 @@ describe('Email renderer', function () {
});
describe('createUnsubscribeUrl', function () {
- it('includes member uuid and newsletter id', async function () {
- const emailRenderer = new EmailRenderer({
+ let emailRenderer;
+
+ beforeEach(function () {
+ emailRenderer = new EmailRenderer({
urlUtils: {
urlFor() {
return 'http://example.com/subdirectory';
}
+ },
+ settingsHelpers: {
+ getMembersValidationKey
}
});
+ });
+
+ it('includes member uuid and newsletter id', async function () {
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
newsletterUuid: 'newsletteruuid'
});
- assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&newsletter=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`);
});
it('includes comments', async function () {
- const emailRenderer = new EmailRenderer({
- urlUtils: {
- urlFor() {
- return 'http://example.com/subdirectory';
- }
- }
- });
const response = await emailRenderer.createUnsubscribeUrl('memberuuid', {
comments: true
});
- assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&comments=1`);
+ const memberHmac = crypto.createHmac('sha256', getMembersValidationKey()).update('memberuuid').digest('hex');
+ assert.equal(response, `http://example.com/subdirectory/unsubscribe/?uuid=memberuuid&key=${memberHmac}&comments=1`);
});
it('works for previews', async function () {
- const emailRenderer = new EmailRenderer({
- urlUtils: {
- urlFor() {
- return 'http://example.com/subdirectory';
- }
- }
- });
const response = await emailRenderer.createUnsubscribeUrl();
assert.equal(response, `http://example.com/subdirectory/unsubscribe/?preview=1`);
});
| | | | |