Skip to content

Commit

Permalink
🔒 Added uuid verification to member endpoints not requiring a session
Browse files Browse the repository at this point in the history
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
  • Loading branch information
9larsons authored and daniellockyer committed Aug 20, 2024
1 parent beb70e9 commit dac2561
Show file tree
Hide file tree
Showing 32 changed files with 1,027 additions and 290 deletions.
2 changes: 1 addition & 1 deletion apps/portal/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@tryghost/portal",
"version": "2.38.0",
"version": "2.39.0",
"license": "MIT",
"repository": {
"type": "git",
Expand Down
70 changes: 48 additions & 22 deletions apps/portal/src/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '';
Expand Down Expand Up @@ -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') {
Expand All @@ -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)) {
Expand Down
76 changes: 69 additions & 7 deletions apps/portal/src/components/pages/AccountEmailPage.js
Original file line number Diff line number Diff line change
@@ -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 (
<>
<p className={`gh-portal-text-center gh-portal-header-message ${hideClassName}`}>
<Interpolate
syntax={SYNTAX_I18NEXT}
string={t('{{memberEmail}} will no longer receive emails when someone replies to your comments.')}
mapping={{
memberEmail: <strong>{member?.email}</strong>
}}
/>
</p>
</>
);
}
const unsubscribedNewsletter = siteNewsletters?.find((d) => {
return d.uuid === pageData.newsletterUuid;
});

if (!unsubscribedNewsletter) {
return null;
}

const hideClassName = hasInteracted ? 'gh-portal-hide' : '';
return (
<>
<p className={`gh-portal-text-center gh-portal-header-message ${hideClassName}`}>
<Interpolate
syntax={SYNTAX_I18NEXT}
string={t('{{memberEmail}} will no longer receive {{newsletterName}} newsletter.')}
mapping={{
memberEmail: <strong>{member?.email}</strong>,
newsletterName: <strong>{unsubscribedNewsletter?.name}</strong>
}}
/>
</p>
</>
);
};

const defaultSubscribedNewsletters = [...(member?.newsletters || [])];
const [subscribedNewsletters, setSubscribedNewsletters] = useState(defaultSubscribedNewsletters);
const {comments_enabled: commentsEnabled} = site;
Expand All @@ -28,7 +90,7 @@ export default function AccountEmailPage() {

return (
<NewsletterManagement
notification={null}
notification={newsletterUuid ? HeaderNotification : null}
subscribedNewsletters={subscribedNewsletters}
updateSubscribedNewsletters={(updatedNewsletters) => {
setSubscribedNewsletters(updatedNewsletters);
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/components/pages/AccountEmailPage.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'});
});
});
14 changes: 6 additions & 8 deletions apps/portal/src/components/pages/FeedbackPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}) => {
Expand Down Expand Up @@ -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);
Expand All @@ -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.'));
Expand All @@ -341,6 +340,5 @@ export default function FeedbackPage() {
return <LoadingFeedbackView action={doSendFeedback} score={score} />;
}
}

return (<ConfirmFeedback positive={positive} />);
}
40 changes: 40 additions & 0 deletions apps/portal/src/components/pages/FeedbackPage.test.js
Original file line number Diff line number Diff line change
@@ -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(
<FeedbackPage />,
{
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();
});
});
2 changes: 1 addition & 1 deletion apps/portal/src/components/pages/LoadingPage.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ export default class LoadingPage extends React.Component {
return (
<div style={{display: 'flex', flexDirection: 'column', color: '#313131'}}>
<div style={{paddingLeft: '16px', paddingRight: '16px', paddingTop: '12px', height: '50px'}}>
<LoaderIcon className={'gh-portal-loadingicon dark'} />
<LoaderIcon className={'gh-portal-loadingicon dark'} data-testid="loaderIcon" />
</div>
</div>
);
Expand Down
10 changes: 5 additions & 5 deletions apps/portal/src/components/pages/UnsubscribePage.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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);
};
Expand All @@ -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);
};
Expand Down Expand Up @@ -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) {
Expand Down
Loading

0 comments on commit dac2561

Please sign in to comment.