From f71c074d31883dc3d2eff2380824682294c7229b Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Wed, 6 Sep 2023 17:11:14 +0200 Subject: [PATCH] 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) --- .../src/api/external-ghost-site.ts | 92 +++++++++++++++++++ .../AddRecommendationModal.tsx | 55 ++++++++--- .../admin-x-settings/src/utils/apiRequests.ts | 1 + .../components/pages/RecommendationsPage.js | 5 +- .../utils/serializers/output/site.js | 2 + .../server/services/public-config/site.js | 4 +- .../settings-helpers/SettingsHelpers.js | 7 ++ .../services/settings/settings-service.js | 1 + ghost/core/core/server/web/members/app.js | 7 ++ .../core/core/shared/settings-cache/public.js | 1 + .../admin/__snapshots__/settings.test.js.snap | 30 +++++- .../admin/__snapshots__/site.test.js.snap | 2 + .../core/test/e2e-api/admin/settings.test.js | 2 +- .../__snapshots__/settings.test.js.snap | 1 + .../shared/__snapshots__/version.test.js.snap | 12 +++ 15 files changed, 202 insertions(+), 20 deletions(-) create mode 100644 apps/admin-x-settings/src/api/external-ghost-site.ts diff --git a/apps/admin-x-settings/src/api/external-ghost-site.ts b/apps/admin-x-settings/src/api/external-ghost-site.ts new file mode 100644 index 0000000000..8bfc1ae5d5 --- /dev/null +++ b/apps/admin-x-settings/src/api/external-ghost-site.ts @@ -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 = {}) => { + 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; + } + } + }; +}; diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx index b080eda450..93cb86fed2 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx @@ -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 = ({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 = ({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: () => { diff --git a/apps/admin-x-settings/src/utils/apiRequests.ts b/apps/admin-x-settings/src/utils/apiRequests.ts index a7cc56c5f8..c0d8f3d845 100644 --- a/apps/admin-x-settings/src/utils/apiRequests.ts +++ b/apps/admin-x-settings/src/utils/apiRequests.ts @@ -20,6 +20,7 @@ interface RequestOptions { headers?: { 'Content-Type'?: string; }; + credentials?: 'include' | 'omit' | 'same-origin'; } export class ApiError extends Error { diff --git a/apps/portal/src/components/pages/RecommendationsPage.js b/apps/portal/src/components/pages/RecommendationsPage.js index aca8f8c8a3..5d30e69706 100644 --- a/apps/portal/src/components/pages/RecommendationsPage.js +++ b/apps/portal/src/components/pages/RecommendationsPage.js @@ -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 (
@@ -61,7 +62,7 @@ const RecommendationItem = ({title, url, reason, favicon}) => { {reason &&

{reason}

}
); diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/site.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/site.js index 73c21560d0..dcdbf9abac 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/site.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/site.js @@ -11,10 +11,12 @@ module.exports = { 'description', 'logo', 'icon', + 'cover_image', 'accent_color', 'locale', 'url', 'version', + 'allow_self_signup', 'sentry_dsn', 'sentry_env' ]) diff --git a/ghost/core/core/server/services/public-config/site.js b/ghost/core/core/server/services/public-config/site.js index a07243c623..fda63ccdc0 100644 --- a/ghost/core/core/server/services/public-config/site.js +++ b/ghost/core/core/server/services/public-config/site.js @@ -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) { diff --git a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js index a6337ad0cb..ab2ffb3b0b 100644 --- a/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js +++ b/ghost/core/core/server/services/settings-helpers/SettingsHelpers.js @@ -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} diff --git a/ghost/core/core/server/services/settings/settings-service.js b/ghost/core/core/server/services/settings/settings-service.js index 3f5fbc0445..a610aa719c 100644 --- a/ghost/core/core/server/services/settings/settings-service.js +++ b/ghost/core/core/server/services/settings/settings-service.js @@ -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']})); diff --git a/ghost/core/core/server/web/members/app.js b/ghost/core/core/server/web/members/app.js index 287a0b5c57..456e62b104 100644 --- a/ghost/core/core/server/web/members/app.js +++ b/ghost/core/core/server/web/members/app.js @@ -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)); diff --git a/ghost/core/core/shared/settings-cache/public.js b/ghost/core/core/shared/settings-cache/public.js index 31dee51aa0..4797d1b86c 100644 --- a/ghost/core/core/shared/settings-cache/public.js +++ b/ghost/core/core/shared/settings-cache/public.js @@ -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', diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap index cdc530a746..52146c7e17 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/settings.test.js.snap @@ -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, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/site.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/site.test.js.snap index c66a3cb665..f4a9615adf 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/site.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/site.test.js.snap @@ -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", diff --git a/ghost/core/test/e2e-api/admin/settings.test.js b/ghost/core/test/e2e-api/admin/settings.test.js index 3d568af2be..82cea6b6ff 100644 --- a/ghost/core/test/e2e-api/admin/settings.test.js +++ b/ghost/core/test/e2e-api/admin/settings.test.js @@ -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 = {}; diff --git a/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap b/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap index 4ce1767887..21d9e550dc 100644 --- a/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap +++ b/ghost/core/test/e2e-api/content/__snapshots__/settings.test.js.snap @@ -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", diff --git a/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap b/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap index 0cba8154ba..af202d34eb 100644 --- a/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap +++ b/ghost/core/test/e2e-api/shared/__snapshots__/version.test.js.snap @@ -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",