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:
parent
712da704f7
commit
f71c074d31
15 changed files with 202 additions and 20 deletions
92
apps/admin-x-settings/src/api/external-ghost-site.ts
Normal file
92
apps/admin-x-settings/src/api/external-ghost-site.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
|
@ -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: () => {
|
||||
|
|
|
@ -20,6 +20,7 @@ interface RequestOptions {
|
|||
headers?: {
|
||||
'Content-Type'?: string;
|
||||
};
|
||||
credentials?: 'include' | 'omit' | 'same-origin';
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -11,10 +11,12 @@ module.exports = {
|
|||
'description',
|
||||
'logo',
|
||||
'icon',
|
||||
'cover_image',
|
||||
'accent_color',
|
||||
'locale',
|
||||
'url',
|
||||
'version',
|
||||
'allow_self_signup',
|
||||
'sentry_dsn',
|
||||
'sentry_env'
|
||||
])
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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']}));
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 = {};
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Add table
Reference in a new issue