0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-02-03 23:00:14 -05:00

Added one-click-subscribe detection (#17995)

fixes https://github.com/TryGhost/Product/issues/3820

- This adds a new public site endpoint in the members API to check if a
site can offer the one-click-subscribe feature
- This is implemented on the members API as a copy of the `site`
endpoint because the admin API site endpoint is protected by CORS and
mainly because it can be served on a different domain than the
recommended site and this is hard to detect reliably from the frontend
- Added a new calculated setting `allow_self_signup`, which can replace
the setting that is currently used in Portal (best to do this after a
release otherwise we risk creating issues if a patch release happens)
This commit is contained in:
Simon Backx 2023-09-06 17:11:14 +02:00 committed by GitHub
parent 712da704f7
commit f71c074d31
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
15 changed files with 202 additions and 20 deletions

View file

@ -0,0 +1,92 @@
import {useFetchApi} from '../utils/apiRequests';
export type GhostSiteResponse = {
site: {
title: string,
description: string | null,
logo: URL | null,
icon: URL | null,
cover_image : URL | null,
allow_self_signup: boolean,
url: URL,
}
}
export const apiUrl = (root: string, path: string, searchParams: Record<string, string> = {}) => {
const url = new URL(`${root}${path}`, window.location.origin);
url.search = new URLSearchParams(searchParams).toString();
return url.toString();
};
export const useExternalGhostSite = () => {
const fetchApi = useFetchApi();
const path = '/members/api/site';
return {
async query(root: string) {
// Remove trailing slash
root = root.replace(/\/$/, '');
const url = apiUrl(root, path);
try {
const result = await fetchApi(url, {
method: 'GET',
credentials: 'omit' // Allow CORS wildcard
});
// We need to validate all data types here for extra safety
if (typeof result !== 'object' || !result.site || typeof result.site !== 'object' || typeof result.site.title !== 'string' || typeof result.site.allow_self_signup !== 'boolean' || typeof result.site.url !== 'string') {
// eslint-disable-next-line no-console
console.warn('Received invalid response from external Ghost site API', result);
return null;
}
if (result.site.description !== null && typeof result.site.description !== 'string') {
// eslint-disable-next-line no-console
console.warn('Received invalid response from external Ghost site API', result);
return null;
}
if (result.site.logo !== null && typeof result.site.logo !== 'string') {
// eslint-disable-next-line no-console
console.warn('Received invalid response from external Ghost site API', result);
return null;
}
if (result.site.icon !== null && typeof result.site.icon !== 'string') {
// eslint-disable-next-line no-console
console.warn('Received invalid response from external Ghost site API', result);
return null;
}
if (result.site.cover_image !== null && typeof result.site.cover_image !== 'string') {
// eslint-disable-next-line no-console
console.warn('Received invalid response from external Ghost site API', result);
return null;
}
// Validate URLs
try {
return {
site: {
title: result.site.title,
description: result.site.description,
logo: result.site.logo ? new URL(result.site.logo) : null,
icon: result.site.icon ? new URL(result.site.icon) : null,
cover_image: result.site.cover_image ? new URL(result.site.cover_image) : null,
allow_self_signup: result.site.allow_self_signup,
url: new URL(result.site.url)
}
};
} catch (e) {
// eslint-disable-next-line no-console
console.warn('Received invalid response from external Ghost site API', result, e);
return null;
}
} catch (e) {
// eslint-disable-next-line no-console
console.error(e);
return null;
}
}
};
};

View file

@ -9,6 +9,7 @@ import useRouting from '../../../../hooks/useRouting';
import {EditOrAddRecommendation} from '../../../../api/recommendations';
import {showToast} from '../../../../admin-x-ds/global/Toast';
import {toast} from 'react-hot-toast';
import {useExternalGhostSite} from '../../../../api/external-ghost-site';
import {useGetOembed} from '../../../../api/oembed';
interface AddRecommendationModalProps {
@ -20,6 +21,7 @@ const AddRecommendationModal: React.FC<AddRecommendationModalProps> = ({recommen
const modal = useModal();
const {updateRoute} = useRouting();
const {query: queryOembed} = useGetOembed();
const {query: queryExternalGhostSite} = useExternalGhostSite();
const {formState, updateForm, handleSave, errors, validate, saveState, clearError} = useForm({
initialState: recommendation ?? {
@ -32,33 +34,56 @@ const AddRecommendationModal: React.FC<AddRecommendationModalProps> = ({recommen
one_click_subscribe: false
},
onSave: async () => {
// Todo: Fetch metadata and pass it along
const oembed = await queryOembed({
url: formState.url,
type: 'mention'
});
let validatedUrl: URL | null = null;
try {
validatedUrl = new URL(formState.url);
} catch (e) {
// Ignore
}
// First check if it s a Ghost site or not
let externalGhostSite = validatedUrl && validatedUrl.protocol === 'https:' ? (await queryExternalGhostSite('https://' + validatedUrl.host)) : null;
let defaultTitle = formState.title;
if (!defaultTitle) {
try {
defaultTitle = new URL(formState.url).hostname.replace('www.', '');
} catch (e) {
if (validatedUrl) {
defaultTitle = validatedUrl.hostname.replace('www.', '');
} else {
// Ignore
defaultTitle = formState.url;
}
}
const updatedRecommendation = {
...formState,
title: defaultTitle
};
if (externalGhostSite) {
// For Ghost sites, we use the data from the API
updatedRecommendation.title = externalGhostSite.site.title || defaultTitle;
updatedRecommendation.excerpt = externalGhostSite.site.description ?? formState.excerpt ?? null;
updatedRecommendation.featured_image = externalGhostSite.site.cover_image?.toString() ?? formState.featured_image ?? null;
updatedRecommendation.favicon = externalGhostSite.site.icon?.toString() ?? externalGhostSite.site.logo?.toString() ?? formState.favicon ?? null;
updatedRecommendation.one_click_subscribe = externalGhostSite.site.allow_self_signup;
updatedRecommendation.url = externalGhostSite.site.url.toString();
} else {
// For non-Ghost sites, we use the Oemebd API to fetch metadata
const oembed = await queryOembed({
url: formState.url,
type: 'mention'
});
updatedRecommendation.title = oembed?.metadata?.title ?? defaultTitle;
updatedRecommendation.excerpt = oembed?.metadata?.description ?? formState.excerpt ?? null;
updatedRecommendation.featured_image = oembed?.metadata?.thumbnail ?? formState.featured_image ?? null;
updatedRecommendation.favicon = oembed?.metadata?.icon ?? formState.favicon ?? null;
updatedRecommendation.one_click_subscribe = false;
}
// Switch modal without changing the route (the second modal is not reachable by URL)
modal.remove();
NiceModal.show(AddRecommendationModalConfirm, {
animate: false,
recommendation: {
...formState,
title: oembed?.metadata?.title ?? defaultTitle,
excerpt: oembed?.metadata?.description ?? formState.excerpt ?? null,
featured_image: oembed?.metadata?.thumbnail ?? formState.featured_image ?? null,
favicon: oembed?.metadata?.icon ?? formState.favicon ?? null
}
recommendation: updatedRecommendation
});
},
onValidate: () => {

View file

@ -20,6 +20,7 @@ interface RequestOptions {
headers?: {
'Content-Type'?: string;
};
credentials?: 'include' | 'omit' | 'same-origin';
}
export class ApiError extends Error {

View file

@ -48,8 +48,9 @@ const shuffleRecommendations = (array) => {
return array;
};
const RecommendationItem = ({title, url, reason, favicon}) => {
const RecommendationItem = (recommendation) => {
const {t} = useContext(AppContext);
const {title, url, reason, favicon, one_click_subscribe: oneClickSubscribe} = recommendation;
return (
<section className="gh-portal-recommendation-item">
@ -61,7 +62,7 @@ const RecommendationItem = ({title, url, reason, favicon}) => {
{reason && <p>{reason}</p>}
</div>
<div>
<a href={url} target="_blank" rel="noopener noreferrer" className="gh-portal-btn gh-portal-btn-list">{t('Visit')}</a>
<a href={url} target="_blank" rel="noopener noreferrer" className="gh-portal-btn gh-portal-btn-list">{oneClickSubscribe ? t('Subscribe') : t('Visit')}</a>
</div>
</section>
);

View file

@ -11,10 +11,12 @@ module.exports = {
'description',
'logo',
'icon',
'cover_image',
'accent_color',
'locale',
'url',
'version',
'allow_self_signup',
'sentry_dsn',
'sentry_env'
])

View file

@ -9,10 +9,12 @@ module.exports = function getSiteProperties() {
description: settingsCache.get('description'),
logo: settingsCache.get('logo'),
icon: settingsCache.get('icon'),
cover_image: settingsCache.get('cover_image'),
accent_color: settingsCache.get('accent_color'),
locale: settingsCache.get('locale'),
url: urlUtils.urlFor('home', true),
version: ghostVersion.safe
version: ghostVersion.safe,
allow_self_signup: settingsCache.get('allow_self_signup')
};
if (config.get('client_sentry') && !config.get('client_sentry').disabled) {

View file

@ -20,6 +20,13 @@ class SettingsHelpers {
return this.settingsCache.get('members_signup_access') === 'invite';
}
/**
* NOTE! The backend still allows to self signup if this returns false because a site might use built-in free signup forms apart from Portal
*/
allowSelfSignup() {
return this.settingsCache.get('members_signup_access') === 'all' && (this.settingsCache.get('portal_plans').includes('free') || !this.arePaidMembersEnabled());
}
/**
* @param {'direct' | 'connect'} type - The "type" of keys to fetch from settings
* @returns {{publicKey: string, secretKey: string} | null}

View file

@ -87,6 +87,7 @@ module.exports = {
fields.push(new CalculatedField({key: 'members_enabled', type: 'boolean', group: 'members', fn: settingsHelpers.isMembersEnabled.bind(settingsHelpers), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'members_invite_only', type: 'boolean', group: 'members', fn: settingsHelpers.isMembersInviteOnly.bind(settingsHelpers), dependents: ['members_signup_access']}));
fields.push(new CalculatedField({key: 'allow_self_signup', type: 'boolean', group: 'members', fn: settingsHelpers.allowSelfSignup.bind(settingsHelpers), dependents: ['members_signup_access', 'portal_plans', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_publishable_key']}));
fields.push(new CalculatedField({key: 'paid_members_enabled', type: 'boolean', group: 'members', fn: settingsHelpers.arePaidMembersEnabled.bind(settingsHelpers), dependents: ['members_signup_access', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_publishable_key']}));
fields.push(new CalculatedField({key: 'firstpromoter_account', type: 'string', group: 'firstpromoter', fn: settingsHelpers.getFirstpromoterId.bind(settingsHelpers), dependents: ['firstpromoter', 'firstpromoter_id']}));
fields.push(new CalculatedField({key: 'donations_enabled', type: 'boolean', group: 'donations', fn: settingsHelpers.areDonationsEnabled.bind(settingsHelpers), dependents: ['stripe_secret_key', 'stripe_publishable_key', 'stripe_connect_secret_key', 'stripe_connect_publishable_key']}));

View file

@ -88,6 +88,13 @@ module.exports = function setupMembersApp() {
announcementRouter()
);
// Allow external systems to read public settings via the members api
// Without CORS issues and without a required integration token
// 1. Detect if a site is Running Ghost
// 2. For recommendations to know when we can offer 'one-click-subscribe' to know if members are enabled
// Why not content API? Domain can be different from recommended domain + CORS issues
membersApp.get('/api/site', http(api.site.read));
// API error handling
membersApp.use('/api', errorHandler.resourceNotFound);
membersApp.use('/api', errorHandler.handleJSONResponse(sentry));

View file

@ -28,6 +28,7 @@ module.exports = {
twitter_description: 'twitter_description',
members_support_address: 'members_support_address',
members_enabled: 'members_enabled',
allow_self_signup: 'allow_self_signup',
members_invite_only: 'members_invite_only',
paid_members_enabled: 'paid_members_enabled',
firstpromoter_account: 'firstpromoter_account',

View file

@ -324,6 +324,10 @@ Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": true,
@ -730,6 +734,10 @@ Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": true,
@ -750,7 +758,7 @@ exports[`Settings API Edit Can edit a setting 2: [headers] 1`] = `
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": "4193",
"content-length": "4234",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
@ -1084,6 +1092,10 @@ Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": true,
@ -1437,6 +1449,10 @@ Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": true,
@ -1795,6 +1811,10 @@ Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": true,
@ -2241,6 +2261,10 @@ Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": true,
@ -2659,6 +2683,10 @@ Object {
"key": "members_invite_only",
"value": false,
},
Object {
"key": "allow_self_signup",
"value": true,
},
Object {
"key": "paid_members_enabled",
"value": true,

View file

@ -4,6 +4,8 @@ exports[`Site API can retrieve config and all expected properties 1: [body] 1`]
Object {
"site": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"icon": null,
"locale": "en",

View file

@ -8,7 +8,7 @@ const {stringMatching, anyEtag, anyUuid, anyContentLength, anyContentVersion} =
const models = require('../../../core/server/models');
const {anyErrorId} = matchers;
const CURRENT_SETTINGS_COUNT = 83;
const CURRENT_SETTINGS_COUNT = 84;
const settingsMatcher = {};

View file

@ -5,6 +5,7 @@ Object {
"meta": Object {},
"settings": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"codeinjection_foot": null,
"codeinjection_head": null,
"comments_enabled": "off",

View file

@ -4,6 +4,8 @@ exports[`API Versioning Admin API Does an internal rewrite for canary URLs with
Object {
"site": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"icon": null,
"locale": "en",
@ -81,6 +83,8 @@ exports[`API Versioning Admin API allows invalid accept-version header 1: [body]
Object {
"site": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"icon": null,
"locale": "en",
@ -227,6 +231,8 @@ exports[`API Versioning Admin API responds with content version header even when
Object {
"site": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"icon": null,
"locale": "en",
@ -255,6 +261,8 @@ exports[`API Versioning Admin API responds with current content version header w
Object {
"site": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"icon": null,
"locale": "en",
@ -283,6 +291,8 @@ exports[`API Versioning Admin API responds with current content version header w
Object {
"site": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"cover_image": "https://static.ghost.org/v5.0.0/images/publication-cover.jpg",
"description": "Thoughts, stories and ideas",
"icon": null,
"locale": "en",
@ -1339,6 +1349,7 @@ Object {
"meta": Object {},
"settings": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"codeinjection_foot": null,
"codeinjection_head": null,
"comments_enabled": "off",
@ -1436,6 +1447,7 @@ Object {
"meta": Object {},
"settings": Object {
"accent_color": "#FF1A75",
"allow_self_signup": true,
"codeinjection_foot": null,
"codeinjection_head": null,
"comments_enabled": "off",