Merge branch 'main' into main

This commit is contained in:
Shah Newaj 2024-07-09 12:38:56 -04:00 committed by GitHub
commit d1affa6c12
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
88 changed files with 5849 additions and 4992 deletions

View File

@ -1,4 +1,4 @@
Copyright (c) 2013-2023 Ghost Foundation
Copyright (c) 2013-2024 Ghost Foundation
Permission is hereby granted, free of charge, to any person
obtaining a copy of this software and associated documentation

View File

@ -17,7 +17,7 @@
<a href="https://twitter.com/ghost">Twitter</a>
<br /><br />
<a href="https://ghost.org/">
<img src="https://img.shields.io/badge/downloads-3M-brightgreen.svg" alt="Downloads" />
<img src="https://img.shields.io/badge/downloads-100M+-brightgreen.svg" alt="Downloads" />
</a>
<a href="https://github.com/TryGhost/Ghost/releases/">
<img src="https://img.shields.io/github/release/TryGhost/Ghost.svg" alt="Latest release" />
@ -82,7 +82,7 @@ For anyone wishing to contribute to Ghost or to hack/customize core files we rec
# Ghost sponsors
We'd like to extend big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
A big thanks to our sponsors and partners who make Ghost possible. If you're interested in sponsoring Ghost and supporting the project, please check out our profile on [GitHub sponsors](https://github.com/sponsors/TryGhost) :heart:
**[DigitalOcean](https://m.do.co/c/9ff29836d717)** • **[Fastly](https://www.fastly.com/)**
@ -90,12 +90,13 @@ We'd like to extend big thanks to our sponsors and partners who make Ghost possi
# Getting help
You can find answers to a huge variety of questions, along with a large community of helpful developers over on the [Ghost forum](https://forum.ghost.org/) - replies are generally very quick. **Ghost(Pro)** customers also have access to 24/7 email support.
Everyone can get help and support from a large community of developers over on the [Ghost forum](https://forum.ghost.org/). **Ghost(Pro)** customers have access to 24/7 email support.
To stay up to date with all the latest news and product updates, make sure you [subscribe to our blog](https://ghost.org/blog/) — or you can always follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
To stay up to date with all the latest news and product updates, make sure you [subscribe to our changelog newsletter](https://ghost.org/changelog/) — or follow us [on Twitter](https://twitter.com/Ghost), if you prefer your updates bite-sized and facetious. :saxophone::turtle:
&nbsp;
# Copyright & license
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).
Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.

View File

@ -36,10 +36,13 @@
"@testing-library/react": "14.1.0",
"@tryghost/admin-x-design-system": "0.0.0",
"@tryghost/admin-x-framework": "0.0.0",
"@types/jest": "29.5.12",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"jest": "29.7.0",
"react": "18.3.1",
"react-dom": "18.3.1"
"react-dom": "18.3.1",
"ts-jest": "29.1.5"
},
"nx": {
"targets": {

View File

@ -1,5 +1,6 @@
import NiceModal from '@ebay/nice-modal-react';
import {Modal, TextField, showToast} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useFollow} from '@tryghost/admin-x-framework/api/activitypub';
import {useQueryClient} from '@tryghost/admin-x-framework';
import {useRouting} from '@tryghost/admin-x-framework/routing';
@ -16,6 +17,9 @@ const FollowSite = NiceModal.create(() => {
const modal = NiceModal.useModal();
const mutation = useFollow();
const client = useQueryClient();
const site = useBrowseSite();
const siteData = site.data?.site;
const siteUrl = siteData?.url ?? window.location.origin;
// mutation.isPending
// mutation.isError
@ -30,8 +34,11 @@ const FollowSite = NiceModal.create(() => {
const handleFollow = async () => {
try {
const url = new URL(`.ghost/activitypub/actions/follow/${profileName}`, siteUrl);
await fetch(url, {
method: 'POST'
});
// Perform the mutation
await mutation.mutateAsync({username: profileName});
// If successful, set the success state to true
// setSuccess(true);
showToast({

View File

@ -1,10 +1,9 @@
// import NiceModal from '@ebay/nice-modal-react';
// import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import React, {useState} from 'react';
import ActivityPubWelcomeImage from '../assets/images/ap-welcome.png';
import React, {useEffect, useRef, useState} from 'react';
import articleBodyStyles from './articleBodyStyles';
import getUsername from '../utils/get-username';
import {ActorProperties, ObjectProperties, useBrowseFollowersForUser, useBrowseFollowingForUser, useBrowseInboxForUser} from '@tryghost/admin-x-framework/api/activitypub';
import {Avatar, Button, Heading, List, ListItem, Page, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
import {Avatar, Button, ButtonGroup, Heading, List, ListItem, Page, SelectOption, SettingValue, ViewContainer, ViewTab} from '@tryghost/admin-x-design-system';
import {useBrowseSite} from '@tryghost/admin-x-framework/api/site';
import {useRouting} from '@tryghost/admin-x-framework/routing';
@ -33,6 +32,8 @@ const ActivityPubComponent: React.FC = () => {
setArticleContent(null);
};
const [selectedOption, setSelectedOption] = useState<SelectOption>({label: 'Inbox', value: 'inbox'});
const [selectedTab, setSelectedTab] = useState('inbox');
const tabs: ViewTab[] = [
@ -40,15 +41,15 @@ const ActivityPubComponent: React.FC = () => {
id: 'inbox',
title: 'Inbox',
contents: <div className='grid grid-cols-6 items-start gap-8'>
<ul className='order-2 col-span-6 flex flex-col lg:order-1 lg:col-span-4'>
<ul className={`order-2 col-span-6 flex flex-col pb-8 lg:order-1 ${selectedOption.value === 'inbox' ? 'lg:col-span-4' : 'lg:col-span-3'}`}>
{activities && activities.some(activity => activity.type === 'Create' && activity.object.type === 'Article') ? (activities.slice().reverse().map(activity => (
activity.type === 'Create' && activity.object.type === 'Article' &&
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object}/>
</li>
))) : <div className='flex items-center justify-center text-center'>
<div className='flex max-w-[32em] flex-col items-center justify-center gap-4'>
{/* <img alt='Ghost site logos' className='w-[220px]' src={ActivityPubWelcomeImage}/> */}
<img alt='Ghost site logos' className='w-[220px]' src={ActivityPubWelcomeImage}/>
<Heading className='text-balance' level={2}>Welcome to ActivityPub</Heading>
<p className='text-pretty text-grey-800'>Were so glad to have you on board! At the moment, you can follow other Ghost sites and enjoy their content right here inside Ghost.</p>
<p className='text-pretty text-grey-800'>You can see all of the users on the rightfind your favorite ones and give them a follow.</p>
@ -78,7 +79,7 @@ const ActivityPubComponent: React.FC = () => {
{activities && activities.slice().reverse().map(activity => (
activity.type === 'Create' && activity.object.type === 'Article' &&
<li key={activity.id} data-test-view-article onClick={() => handleViewContent(activity.object, activity.actor)}>
<ObjectContentDisplay actor={activity.actor} object={activity.object}/>
<ObjectContentDisplay actor={activity.actor} layout={selectedOption.value} object={activity.object} />
</li>
))}
</ul>
@ -91,6 +92,25 @@ const ActivityPubComponent: React.FC = () => {
<Page>
{!articleContent ? (
<ViewContainer
actions={[<ButtonGroup buttons={[
{
icon: 'listview',
size: 'sm',
iconColorClass: selectedOption.value === 'inbox' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Inbox', value: 'inbox'});
}
},
{
icon: 'cardview',
size: 'sm',
iconColorClass: selectedOption.value === 'feed' ? 'text-black' : 'text-grey-500',
onClick: () => {
setSelectedOption({label: 'Feed', value: 'feed'});
}
}
]} clearBg={false} link outlineOnMobile />]}
firstOnPage={true}
primaryAction={{
title: 'Follow',
@ -102,9 +122,10 @@ const ActivityPubComponent: React.FC = () => {
selectedTab={selectedTab}
stickyHeader={true}
tabs={tabs}
toolbarBorder={false}
type='page'
onTabChange={setSelectedTab}
title='ActivityPub'
toolbarBorder={true}
type='page'
onTabChange={setSelectedTab}
>
</ViewContainer>
@ -117,7 +138,7 @@ const ActivityPubComponent: React.FC = () => {
};
const Sidebar: React.FC<{followingCount: number, followersCount: number, updateRoute: (route: string) => void}> = ({followingCount, followersCount, updateRoute}) => (
<div className='order-1 col-span-6 flex flex-col gap-5 lg:order-2 lg:col-span-2'>
<div className='order-1 col-span-6 flex flex-col gap-5 lg:order-2 lg:col-span-2 lg:col-start-5'>
<div className='rounded-xl bg-grey-50 p-6' id="ap-sidebar">
<div className='mb-4 border-b border-b-grey-200 pb-4'><SettingValue key={'your-username'} heading={'Your username'} value={'@index@localplaceholder.com'}/></div>
<div className='grid grid-cols-2 gap-4'>
@ -146,17 +167,17 @@ const Sidebar: React.FC<{followingCount: number, followersCount: number, updateR
);
const ArticleBody: React.FC<{heading: string, image: string|undefined, html: string}> = ({heading, image, html}) => {
// const dangerouslySetInnerHTML = {__html: html};
// const cssFile = '../index.css';
const site = useBrowseSite();
const siteData = site.data?.site;
const iframeRef = useRef<HTMLIFrameElement>(null);
const cssContent = articleBodyStyles(siteData?.url.replace(/\/$/, ''));
const htmlContent = `
<html>
<head>
${cssContent}
${cssContent}
</head>
<body>
<header class="gh-article-header gh-canvas">
@ -174,20 +195,29 @@ ${image &&
</html>
`;
useEffect(() => {
const iframe = iframeRef.current;
if (iframe) {
iframe.srcdoc = htmlContent;
}
}, [htmlContent]);
return (
<iframe
className='h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]'
height="100%"
id="gh-ap-article-iframe"
srcDoc={htmlContent}
title="Embedded Content"
width="100%"
>
</iframe>
<div>
<iframe
ref={iframeRef}
className={`h-[calc(100vh_-_3vmin_-_4.8rem_-_2px)]`}
height="100%"
id="gh-ap-article-iframe"
title="Embedded Content"
width="100%"
>
</iframe>
</div>
);
};
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties }> = ({actor, object}) => {
const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProperties, layout: string }> = ({actor, object, layout}) => {
const parser = new DOMParser();
const doc = parser.parseFromString(object.content || '', 'text/html');
@ -205,37 +235,75 @@ const ObjectContentDisplay: React.FC<{actor: ActorProperties, object: ObjectProp
setTimeout(() => setIsClicked(false), 300); // Reset the animation class after 300ms
};
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon}/>
<span className='truncate font-semibold'>{actor.name}</span>
<span className='truncate text-grey-800'>{getUsername(actor)}</span>
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-2 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
</div>
<p className='mb-6 line-clamp-2 max-w-prose text-md text-grey-800'>{plainTextContent}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
if (layout === 'feed') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-6' data-test-activity>
<div className='relative z-10 mb-3 flex w-full items-center gap-3'>
<img className='w-8' src={actor.icon.url}/>
<div>
<p className='text-base font-bold' data-test-activity-heading>{actor.name}</p>
<div className='*:text-base *:text-grey-900'>
{/* <span className='truncate before:mx-1 before:content-["·"]'>{getUsername(actor)}</span> */}
<span>{timestamp}</span>
</div>
</div>
</div>
{object.image && <div className='relative min-w-[33%] grow'>
<img className='absolute h-full w-full rounded object-cover' src={object.image}/>
</div>}
<div className='relative z-10 w-full gap-4'>
<div className='flex flex-col'>
{object.image && <div className='relative mb-4'>
<img className='h-[300px] w-full rounded object-cover' src={object.image}/>
</div>}
<Heading className='mb-1 leading-tight' level={4} data-test-activity-heading>{object.name}</Heading>
<p className='mb-4 line-clamp-3 max-w-prose text-pretty text-md text-grey-900'>{plainTextContent}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
</div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-100'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
<div className='absolute -inset-x-3 inset-y-0 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
)}
</>
);
} else if (layout === 'inbox') {
return (
<>
{object && (
<div className='border-1 group/article relative z-10 flex cursor-pointer flex-col items-start justify-between border-b border-b-grey-200 py-5' data-test-activity>
<div className='relative z-10 mb-3 grid w-full grid-cols-[20px_auto_1fr_auto] items-center gap-2 text-base'>
<img className='w-5' src={actor.icon?.url}/>
<span className='truncate font-semibold'>{actor.name}</span>
{/* <span className='truncate text-grey-800'>{getUsername(actor)}</span> */}
<span className='ml-auto text-right text-grey-800'>{timestamp}</span>
</div>
<div className='relative z-10 grid w-full grid-cols-[auto_170px] gap-4'>
<div className='flex flex-col'>
<div className='flex w-full justify-between gap-4'>
<Heading className='mb-1 line-clamp-2 leading-tight' level={5} data-test-activity-heading>{object.name}</Heading>
</div>
<p className='mb-6 line-clamp-2 max-w-prose text-pretty text-md text-grey-800'>{object.preview?.content}</p>
<div className='flex gap-2'>
<Button className={`self-start text-grey-500 transition-all hover:text-grey-800 ${isClicked ? 'bump' : ''} ${isLiked ? 'ap-red-heart text-red *:!fill-red hover:text-red' : ''}`} hideLabel={true} icon='heart' id="like" size='md' unstyled={true} onClick={handleLikeClick}/>
<span className={`text-grey-800 ${isLiked ? 'opacity-100' : 'opacity-0'}`}>1</span>
</div>
</div>
{object.image && <div className='relative min-w-[33%] grow'>
<img className='absolute h-full w-full rounded object-cover' height='140px' src={object.image} width='170px'/>
</div>}
</div>
<div className='absolute -inset-x-3 -inset-y-1 z-0 rounded transition-colors group-hover/article:bg-grey-50'></div>
{/* <div className='absolute inset-0 z-0 rounded from-white to-grey-50 transition-colors group-hover/article:bg-gradient-to-r'></div> */}
</div>
)}
</>
);
}
};
const ViewArticle: React.FC<ViewArticleProps> = ({object, onBackToList}) => {

View File

@ -4,6 +4,7 @@
"lib": ["DOM", "DOM.Iterable", "ESNext"],
"module": "ESNext",
"skipLibCheck": true,
"types": ["vite/client", "jest"],
/* Bundler mode */
"moduleResolution": "bundler",

View File

@ -60,7 +60,7 @@
"@sentry/react": "7.118.0",
"@tailwindcss/forms": "0.5.7",
"@tailwindcss/line-clamp": "0.4.4",
"@uiw/react-codemirror": "4.22.1",
"@uiw/react-codemirror": "4.23.0",
"autoprefixer": "10.4.19",
"clsx": "2.1.1",
"postcss": "8.4.39",

View File

@ -1,5 +1,6 @@
import {arrayMove} from '@dnd-kit/sortable';
import {useEffect, useState} from 'react';
import _ from 'lodash';
export type SortableIndexedList<Item> = {
items: Array<{ item: Item; id: string }>;
@ -32,7 +33,7 @@ const useSortableIndexedList = <Item extends unknown>({items, setItems, blank, c
allItems.push(newItem);
}
if (JSON.stringify(allItems) !== JSON.stringify(items)) {
if (!_.isEqual(JSON.parse(JSON.stringify(allItems)), JSON.parse(JSON.stringify(items)))) {
setItems(allItems);
}
}, [editableItems, newItem, items, setItems, canAddNewItem]);

View File

@ -61,7 +61,7 @@ const MainContent: React.FC = () => {
if (isEditorUser(currentUser)) {
return (
<Page>
<div className='mx-auto w-full max-w-5xl px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
<div className='mx-auto w-full max-w-5xl overflow-y-auto px-[5vmin] tablet:mt-16 xl:mt-10' id="admin-x-settings-scroller">
<Heading className='mb-[5vmin]'>Settings</Heading>
<Users highlight={false} keywords={[]} />
</div>

View File

@ -15,10 +15,6 @@ const features = [{
title: 'Webmentions',
description: 'Allows viewing received mentions on the dashboard.',
flag: 'webmentions'
},{
title: 'Websockets',
description: <>Test out Websockets functionality at <code>/ghost/#/websockets</code>.</>,
flag: 'websockets'
},{
title: 'Stripe Automatic Tax (private beta)',
description: 'Use Stripe Automatic Tax at Stripe Checkout. Needs to be enabled in Stripe',
@ -60,10 +56,6 @@ const features = [{
description: '(Highly) Experimental support for ActivityPub.',
flag: 'ActivityPub'
},{
title: 'Excerpt in newsletter',
description: 'Showing excerpt in newsletter',
flag: 'newsletterExcerpt'
}, {
title: 'Content Visibility',
description: 'Enables content visibility in Emails',
flag: 'contentVisibility'

View File

@ -39,7 +39,7 @@ const AddNewsletterModal: React.FC<RoutingModalProps> = () => {
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Name is required';
newErrors.name = 'A name is required for your newsletter';
}
return newErrors;

View File

@ -103,7 +103,6 @@ const Sidebar: React.FC<{
const {mutateAsync: uploadImage} = useUploadImage();
const [selectedTab, setSelectedTab] = useState('generalSettings');
const hasEmailCustomization = useFeatureFlag('emailCustomization');
const hasNewsletterExcerpt = useFeatureFlag('newsletterExcerpt');
const {localSettings} = useSettingGroup();
const [siteTitle] = getSettingValues(localSettings, ['title']) as string[];
const handleError = useHandleError();
@ -418,7 +417,7 @@ const Sidebar: React.FC<{
onChange={color => updateNewsletter({title_color: color})}
/>}
<ToggleGroup gap='lg'>
{(hasNewsletterExcerpt && newsletter.show_post_title_section) &&
{newsletter.show_post_title_section &&
<Toggle
checked={newsletter.show_excerpt}
direction="rtl"
@ -547,17 +546,17 @@ const NewsletterDetailModalContent: React.FC<{newsletter: Newsletter; onlyOne: b
const newErrors: Record<string, string> = {};
if (!formState.name) {
newErrors.name = 'Name is required';
newErrors.name = 'A name is required for your newsletter';
}
if (formState.sender_email && !validator.isEmail(formState.sender_email)) {
newErrors.sender_email = 'Invalid email';
newErrors.sender_email = 'Enter a valid email address';
} else if (formState.sender_email && hasSendingDomain(config) && formState.sender_email.split('@')[1] !== sendingDomain(config)) {
newErrors.sender_email = `Email must end with @${sendingDomain(config)}`;
newErrors.sender_email = `Email address must end with @${sendingDomain(config)}`;
}
if (formState.sender_reply_to && !validator.isEmail(formState.sender_reply_to) && !['newsletter', 'support'].includes(formState.sender_reply_to)) {
newErrors.sender_reply_to = 'Invalid email';
newErrors.sender_reply_to = 'Enter a valid email address';
}
return newErrors;

View File

@ -77,7 +77,6 @@ const NewsletterPreviewContent: React.FC<{
const showHeader = headerIcon || headerTitle;
const {config} = useGlobalData();
const hasNewEmailAddresses = useFeatureFlag('newEmailAddresses');
const hasNewsletterExcerpt = useFeatureFlag('newsletterExcerpt');
const currentDate = new Date().toLocaleDateString('default', {
year: 'numeric',
@ -154,7 +153,7 @@ const NewsletterPreviewContent: React.FC<{
)} style={{color: titleColor}}>
Your email newsletter
</h2>
{(hasNewsletterExcerpt && showExcerpt) && (
{showExcerpt && (
<p className={excerptClasses}>A subtitle to highlight key points and engage your readers</p>
)}
<div className={clsx(

View File

@ -1,4 +1,5 @@
import React, {FocusEventHandler, useEffect, useState} from 'react';
import validator from 'validator';
import {Form, TextField} from '@tryghost/admin-x-design-system';
import {SettingValue, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {fullEmailAddress, getEmailDomain} from '@tryghost/admin-x-framework/api/site';
@ -19,8 +20,9 @@ const AccountPage: React.FC<{
let supportAddress = e.target.value;
if (!supportAddress) {
setError('members_support_address', 'Please enter an email address');
return;
setError('members_support_address', 'Enter an email address');
} else if (!validator.isEmail(supportAddress)) {
setError('members_support_address', 'Enter a valid email address');
} else {
setError('members_support_address', '');
}

View File

@ -1,5 +1,4 @@
import React, {useCallback, useEffect, useMemo} from 'react';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import {CheckboxGroup, CheckboxProps, Form, HtmlField, Select, SelectOption, Toggle} from '@tryghost/admin-x-design-system';
import {Setting, SettingValue, checkStripeEnabled, getSettingValues} from '@tryghost/admin-x-framework/api/settings';
import {Tier, getPaidActiveTiers} from '@tryghost/admin-x-framework/api/tiers';
@ -14,7 +13,6 @@ const SignupOptions: React.FC<{
setError: (key: string, error: string | undefined) => void
}> = ({localSettings, updateSetting, localTiers, updateTier, errors, setError}) => {
const {config} = useGlobalData();
const hasPortalImprovements = useFeatureFlag('portalImprovements');
const [membersSignupAccess, portalName, portalSignupTermsHtml, portalSignupCheckboxRequired, portalPlansJson, portalDefaultPlan] = getSettingValues(
localSettings, ['members_signup_access', 'portal_name', 'portal_signup_terms_html', 'portal_signup_checkbox_required', 'portal_plans', 'portal_default_plan']
);
@ -52,16 +50,14 @@ const SignupOptions: React.FC<{
updateSetting('portal_plans', JSON.stringify(portalPlans));
// Check default plan is included
if (hasPortalImprovements) {
if (portalDefaultPlan === 'yearly') {
if (!portalPlans.includes('yearly') && portalPlans.includes('monthly')) {
updateSetting('portal_default_plan', 'monthly');
}
} else if (portalDefaultPlan === 'monthly') {
if (!portalPlans.includes('monthly')) {
// If both yearly and monthly are missing from plans, still set it to yearly
updateSetting('portal_default_plan', 'yearly');
}
if (portalDefaultPlan === 'yearly') {
if (!portalPlans.includes('yearly') && portalPlans.includes('monthly')) {
updateSetting('portal_default_plan', 'monthly');
}
} else if (portalDefaultPlan === 'monthly') {
if (!portalPlans.includes('monthly')) {
// If both yearly and monthly are missing from plans, still set it to yearly
updateSetting('portal_default_plan', 'yearly');
}
}
};
@ -79,7 +75,7 @@ const SignupOptions: React.FC<{
tiersCheckboxes.push({
checked: (portalPlans.includes('free')),
disabled: isDisabled,
label: hasPortalImprovements ? tier.name : 'Free',
label: tier.name,
value: 'free',
onChange: (checked) => {
if (portalPlans.includes('free') && !checked) {
@ -158,7 +154,7 @@ const SignupOptions: React.FC<{
]}
title='Prices available at signup'
/>
{(hasPortalImprovements && portalPlans.includes('yearly') && portalPlans.includes('monthly')) &&
{(portalPlans.includes('yearly') && portalPlans.includes('monthly')) &&
<Select
options={defaultPlanOptions}
selectedOption={defaultPlanOptions.find(option => option.value === portalDefaultPlan)}

View File

@ -1,7 +1,6 @@
import NiceModal from '@ebay/nice-modal-react';
import React, {useEffect, useRef} from 'react';
import TierDetailPreview from './TierDetailPreview';
import useFeatureFlag from '../../../../hooks/useFeatureFlag';
import useSettingGroup from '../../../../hooks/useSettingGroup';
import {Button, ButtonProps, ConfirmationModal, CurrencyField, Form, Heading, Icon, Modal, Select, SortableList, TextField, Toggle, URLTextField, showToast, useSortableIndexedList} from '@tryghost/admin-x-design-system';
import {ErrorMessages, useForm, useHandleError} from '@tryghost/admin-x-framework/hooks';
@ -25,8 +24,6 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
const handleError = useHandleError();
const {localSettings, siteData} = useSettingGroup();
const [portalPlansJson] = getSettingValues(localSettings, ['portal_plans']) as string[];
const hasPortalImprovements = useFeatureFlag('portalImprovements');
const allowNameChange = !isFreeTier || hasPortalImprovements;
const portalPlans = JSON.parse(portalPlansJson?.toString() || '[]') as string[];
const validators: {[key in keyof Tier]?: () => string | undefined} = {
@ -70,7 +67,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
} else {
await createTier(values);
}
if (isFreeTier && hasPortalImprovements) {
if (isFreeTier) {
// If we changed the visibility, we also need to update Portal settings in some situations
// Like the free tier is a special case, and should also be present/absent in portal_plans
const visible = formState.visibility === 'public';
@ -196,7 +193,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
<div className='-mb-8 mt-8 flex items-start gap-8'>
<div className='flex grow flex-col gap-8'>
<Form marginBottom={false} title='Basic' grouped>
{allowNameChange && <TextField
<TextField
autoComplete='off'
error={Boolean(errors.name)}
hint={errors.name}
@ -207,7 +204,7 @@ const TierDetailModalContent: React.FC<{tier?: Tier}> = ({tier}) => {
autoFocus
onChange={e => updateForm(state => ({...state, name: e.target.value}))}
onKeyDown={() => clearError('name')}
/>}
/>
<TextField
autoComplete='off'
autoFocus={isFreeTier}

View File

@ -1,6 +1,8 @@
import InvalidThemeModal, {FatalErrors} from './InvalidThemeModal';
import NiceModal from '@ebay/nice-modal-react';
import React from 'react';
import {Button, ButtonProps, ConfirmationModal, List, ListItem, Menu, ModalPage, showToast} from '@tryghost/admin-x-design-system';
import {JSONError} from '@tryghost/admin-x-framework/errors';
import {Theme, isActiveTheme, isDefaultTheme, isDeletableTheme, isLegacyTheme, useActivateTheme, useDeleteTheme} from '@tryghost/admin-x-framework/api/themes';
import {downloadFile, getGhostPaths} from '@tryghost/admin-x-framework/helpers';
import {useHandleError} from '@tryghost/admin-x-framework/hooks';
@ -57,7 +59,26 @@ const ThemeActions: React.FC<ThemeActionProps> = ({
message: <div><span className='capitalize'>{theme.name}</span> is now your active theme</div>
});
} catch (e) {
handleError(e);
let fatalErrors: FatalErrors | null = null;
if (e instanceof JSONError && e.response?.status === 422 && e.data?.errors) {
fatalErrors = (e.data.errors as any) as FatalErrors;
} else {
handleError(e);
}
let title = 'Invalid Theme';
let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme</>;
if (fatalErrors) {
NiceModal.show(InvalidThemeModal, {
title,
prompt,
fatalErrors,
onRetry: async (modal) => {
modal?.remove();
handleActivate();
}
});
}
}
};

View File

@ -31,7 +31,7 @@ const useNavigationEditor = ({items, setItems}: {
const list = useSortableIndexedList<Omit<EditableItem, 'id'>>({
items: items.map(item => ({...item, errors: {}})),
setItems: newItems => setItems(newItems.map(({url, label}) => ({url, label}))),
blank: {label: '', url: '/', errors: {}},
blank: {url: '/',label: '', errors: {}},
canAddNewItem: hasNewItem
});

View File

@ -27,7 +27,7 @@ test.describe('Newsletter settings', async () => {
const modal = page.getByTestId('add-newsletter-modal');
await modal.getByRole('button', {name: 'Create'}).click();
await expect(modal).toHaveText(/Name is required/);
await expect(modal).toHaveText(/A name is required for your newsletter/);
// Shouldn't be necessary, but without these Playwright doesn't click Create the second time for some reason
await modal.getByRole('button', {name: 'Cancel'}).click();
@ -69,7 +69,7 @@ test.describe('Newsletter settings', async () => {
await modal.getByPlaceholder('Weekly Roundup').fill('');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Name is required/);
await expect(modal).toHaveText(/A name is required for your newsletter/);
await modal.getByPlaceholder('Weekly Roundup').fill('Updated newsletter');
@ -114,7 +114,7 @@ test.describe('Newsletter settings', async () => {
await modal.getByLabel('Sender email').fill('not-an-email');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Invalid email/);
await expect(modal).toHaveText(/Enter a valid email address/);
await modal.getByLabel('Sender email').fill('test@test.com');
await modal.getByRole('button', {name: 'Save'}).click();
@ -191,7 +191,7 @@ test.describe('Newsletter settings', async () => {
await replyToEmail.fill('not-an-email');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Invalid email/);
await expect(modal).toHaveText(/Enter a valid email address/);
await replyToEmail.fill('test@test.com');
await modal.getByRole('button', {name: 'Save'}).click();
@ -237,12 +237,12 @@ test.describe('Newsletter settings', async () => {
// Error case #1: add invalid email address
await senderEmail.fill('Harry Potter');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Invalid email/);
await expect(modal).toHaveText(/Enter a valid email address/);
// Error case #2: the sender email address doesn't match the custom sending domain
await senderEmail.fill('harry@potter.com');
await modal.getByRole('button', {name: 'Save'}).click();
await expect(modal).toHaveText(/Email must end with @customdomain.com/);
await expect(modal).toHaveText(/Email address must end with @customdomain.com/);
// But can have any address on the same domain, without verification
await senderEmail.fill('harry@customdomain.com');

View File

@ -168,7 +168,7 @@ test.describe('Theme settings', async () => {
expect(lastApiRequests.uploadTheme).toBeTruthy();
});
test('Limits uploading new themes', async ({page}) => {
test('Limits uploading new themes and redirect to /pro', async ({page}) => {
await mockApi({page, requests: {
...globalDataRequests,
...limitRequests,
@ -206,6 +206,18 @@ test.describe('Theme settings', async () => {
await modal.getByRole('button', {name: 'Upload theme'}).click();
await expect(page.getByTestId('limit-modal')).toHaveText(/Upgrade to enable custom themes/);
const limitModal = page.getByTestId('limit-modal');
await limitModal.getByRole('button', {name: 'Upgrade'}).click();
// The route should be updated
const newPageUrl = page.url();
const newPageUrlObject = new URL(newPageUrl);
const decodedUrl = decodeURIComponent(newPageUrlObject.pathname);
// expect the route to be updated to /pro
await expect(decodedUrl).toMatch(/\/\{\"route\":\"\/pro\",\"isExternal\":true\}$/);
});
test('Prevents overwriting the default theme', async ({page}) => {

View File

@ -12,6 +12,22 @@ Comments widget that is embedded at the bottom of posts in Ghost.
You can automatically start the comments dev server when developing Ghost by running Ghost (in root folder) via `yarn dev --all` or `yarn dev --comments`. This will host the comments JavaScript files, and makes sure that Ghost uses these locally hosted assets instead of the ones from the CDN.
# Copyright & License
## Release
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE).
A patch release can be rolled out instantly in production, whereas a minor/major release requires the Ghost monorepo to be updated and released. In either case, you need sufficient permissions to release `@tryghost` packages on NPM.
### Patch release
1. Run `yarn ship` and select a patch version when prompted
2. (Optional) Clear JsDelivr cache to get the new version out instantly ([docs](https://www.notion.so/ghost/How-to-clear-jsDelivr-CDN-cache-2930bdbac02946eca07ac23ab3199bfa?pvs=4)). Typically, you'll need to open `https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/comments-ui.min.js` and
`https://purge.jsdelivr.net/ghost/comments-ui@~${COMMENTS_UI_VERSION}/umd/main.css` in your browser, where `COMMENTS_UI_VERSION` is the latest minor version in `ghost/core/core/shared/config/defaults.json` ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
### Minor / major release
1. Run `yarn ship` and select a minor or major version when prompted
2. Update the Comments UI version in `ghost/core/core/shared/config/defaults.json` to the new minor or major version ([code](https://github.com/TryGhost/Ghost/blob/0aef3d3beeebcd79a4bfd3ad27e0ac67554b5744/ghost/core/core/shared/config/defaults.json#L198))
3. Wait until a new version of Ghost is released
# Copyright & License
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE).

View File

@ -32,6 +32,10 @@ const AccountWelcome = () => {
return null;
}
if (isComplimentary) {
return null;
}
if (subscriptionHasFreeTrial({sub: subscription})) {
const trialEnd = getDateString(subscription.trial_end_at);
return (

View File

@ -2,51 +2,52 @@
This is the home of the Ember.js-based Admin app that ships with [Ghost](https://github.com/tryghost/ghost).
## Running tests
## Test
Build and run tests once:
### Running tests in the browser
Run all tests in the browser by running `yarn dev` in the Ghost monorepo and visiting http://localhost:4200/tests. The code is hotloaded on change and you can filter which tests to run.
[Testing public documentation](https://ghost.notion.site/Testing-Ember-560cec6700fc4d37a58b3ba9febb4b4b)
---
Tip: You can use `await this.pauseTest()` in your tests to temporarily pause the execution of browser tests. Use the browser console to inspect and debug the DOM, then resume tests by running `resumeTest()` directly in the browser console ([docs](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests))
### Running tests in the CLI
To build and run tests in the CLI, you can use:
```bash
TZ=UTC yarn test
```
_Note the `TZ=UTC` environment variable which is currently required to get tests working if your system timezone doesn't match UTC._
If you are serving the admin app (e.g., when running `yarn serve`, or when running `yarn dev` in the main Ghost project), you can also run the tests in your browser by going to http://localhost:4200/tests.
---
This has the additional benefit that you can use `await this.pauseTest()` in your tests to temporarily pause tests (best to also add `this.timeout(0);` to avoid timeouts). This allows you to inspect the DOM in your browser to debug tests. You can resume tests by running `resumeTest()` in your browser console.
[More information](https://guides.emberjs.com/v3.28.0/testing/testing-application/#toc_debugging-your-tests)
### Writing tests
When writing tests and not using the `http://localhost:4200/tests` browser tests, it can be easier to have a separate watching build that builds the project for the test environment (this drastically reduces the time you have to wait when running tests):
However, this is very slow when writing tests, as it requires the app to be rebuilt on every change. Instead, create a separate watching build with:
```bash
yarn build --environment=test -w -o="dist-test"
```
After that, you can easily run tests locally:
Run all tests:
Then run tests with:
```bash
TZ=UTC yarn test 1 --path="dist-test"
TZ=UTC yarn test 1 --reporter dot --path="dist-test"
```
To have a cleaner output:
The `--reporter dot` shows a dot (`.`) for every successful test, and `F` for every failed test. It renders the output of the failed tests only.
```bash
TZ=UTC yarn test 1 --reporter dot --path="dist-test"
```
This shows a dot (`.`) for every successful test, and `F` for every failed test. At the end, it will only show the output of the failed tests.
---
To run a specific test file:
```bash
TZ=UTC yarn test 1 --reporter dot --path="dist-test" -mp=tests/acceptance/settings/newsletters-test.js
TZ=UTC yarn test 1 --reporter dot --path="dist-test" -mp=tests/unit/helpers/gh-count-characters-test.js
```
_Hint: you can easily copy the path of a test in VSCode by right clicking on the test file and choosing `Copy Relative Path`._
---
To have a full list of the available options, run
```bash
@ -55,4 +56,4 @@ ember exam --help
# Copyright & License
Copyright (c) 2013-2023 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.
Copyright (c) 2013-2024 Ghost Foundation - Released under the [MIT license](LICENSE). Ghost and the Ghost Logo are trademarks of Ghost Foundation Ltd. Please see our [trademark policy](https://ghost.org/trademark/) for info on acceptable usage.

View File

@ -9,8 +9,8 @@ export default class GhAlert extends Component {
const typeMapping = {
success: 'green',
error: 'red',
warn: 'blue',
info: 'blue'
warn: 'black',
info: 'black'
};
const type = this.args.message.type;

View File

@ -143,25 +143,21 @@
{{else}}
{{#if (or (eq sub.price.nickname "Monthly") (eq sub.price.nickname "Yearly"))}}
{{else}}
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span><span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-pricelabel">{{sub.price.nickname}}</span>
{{/if}}
{{/if}}
{{#if sub.isComplimentary}}
{{#if sub.compExpiry}}
<span class="gh-cp-membertier-renewal">Expires {{sub.compExpiry}}</span>
{{/if}}
{{else}}
{{#if sub.hasEnded}}
<span class="gh-cp-membertier-renewal">Ended {{sub.validUntil}}</span>
{{else if sub.willEndSoon}}
<span class="gh-cp-membertier-renewal">Has access until {{sub.validUntil}}</span>
{{else if sub.trialUntil}}
<span class="gh-cp-membertier-renewal">Ends {{sub.trialUntil}}</span>
{{else}}
<span class="gh-cp-membertier-renewal">Renews {{sub.validUntil}}</span>
{{/if}}
{{#if sub.trialUntil}}
<span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
{{/if}}
{{#if sub.compExpiry}}
<span class="gh-cp-membertier-renewal"> &ndash; </span>
<span class="gh-cp-membertier-renewal">{{sub.validityDetails}}</span>
{{/if}}
</div>
<Member::SubscriptionDetailBox @sub={{sub}} @index={{index}} />
</div>

View File

@ -1,8 +1,7 @@
import Component from '@glimmer/component';
import moment from 'moment-timezone';
import {action} from '@ember/object';
import {didCancel, task} from 'ember-concurrency';
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
import {getSubscriptionData} from 'ghost-admin/utils/subscription-data';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
@ -60,41 +59,9 @@ export default class extends Component {
return typeof value.id !== 'undefined' && self.findIndex(element => (element.tier_id || element.id) === (value.tier_id || value.id)) === index;
});
let subscriptionData = subscriptions.filter((sub) => {
return !!sub.price;
}).map((sub) => {
const periodEnded = sub.current_period_end && new Date(sub.current_period_end) < new Date();
const data = {
...sub,
attribution: {
...sub.attribution,
referrerSource: sub.attribution?.referrer_source || 'Unknown',
referrerMedium: sub.attribution?.referrer_medium || '-'
},
startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-',
validUntil: sub.current_period_end ? moment(sub.current_period_end).format('D MMM YYYY') : '-',
hasEnded: sub.status === 'canceled' && periodEnded,
willEndSoon: sub.cancel_at_period_end || (sub.status === 'canceled' && !periodEnded),
cancellationReason: sub.cancellation_reason,
price: {
...sub.price,
currencySymbol: getSymbol(sub.price.currency),
nonDecimalAmount: getNonDecimal(sub.price.amount)
},
isComplimentary: !sub.id
};
if (sub.trial_end_at) {
const inTrialMode = moment(sub.trial_end_at).isAfter(new Date(), 'day');
if (inTrialMode) {
data.trialUntil = moment(sub.trial_end_at).format('D MMM YYYY');
}
}
let subsWithPrice = subscriptions.filter(sub => !!sub.price);
let subscriptionData = subsWithPrice.map(sub => getSubscriptionData(sub));
if (!sub.id && sub.tier?.expiry_at) {
data.compExpiry = moment(sub.tier.expiry_at).utc().format('D MMM YYYY');
}
return data;
});
return tiers.map((tier) => {
let tierSubscriptions = subscriptionData.filter((subscription) => {
return subscription?.price?.tier?.tier_id === (tier.tier_id || tier.id);

View File

@ -1,126 +1,54 @@
import {MATCH_RELATION_OPTIONS} from './relation-options';
export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
if (feature.filterEmailDisabled) {
return {
label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription',
name: 'subscribed',
columnLabel: 'Subscribed',
relationOptions: MATCH_RELATION_OPTIONS,
valueType: 'options',
group: newsletters.length > 1 ? 'Newsletters' : group,
// Only show the filter for multiple newsletters if feature flag is enabled
feature: newsletters.length > 1 ? 'filterEmailDisabled' : undefined,
buildNqlFilter: (flt) => {
const relation = flt.relation;
const value = flt.value;
if (value === 'email-disabled') {
if (relation === 'is') {
return '(email_disabled:1)';
}
return '(email_disabled:0)';
}
if (relation === 'is') {
if (value === 'subscribed') {
return '(subscribed:true+email_disabled:0)';
}
return '(subscribed:false+email_disabled:0)';
}
// relation === 'is-not'
if (value === 'subscribed') {
return '(subscribed:false,email_disabled:1)';
}
return '(subscribed:true,email_disabled:1)';
},
parseNqlFilter: (flt) => {
const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility
if (!comparator || comparator.length !== 2) {
const filter = flt;
if (filter && filter.email_disabled !== undefined) {
if (filter.email_disabled) {
return {
value: 'email-disabled',
relation: 'is'
};
}
return {
value: 'email-disabled',
relation: 'is-not'
};
}
return;
}
if (comparator[0].subscribed === undefined || comparator[1].email_disabled === undefined) {
return;
}
const usedOr = flt.$or !== undefined;
const subscribed = comparator[0].subscribed;
if (usedOr) {
// Is not
return {
value: !subscribed ? 'subscribed' : 'unsubscribed',
relation: 'is-not'
};
}
return {
value: subscribed ? 'subscribed' : 'unsubscribed',
relation: 'is'
};
},
options: [
{label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'},
{label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'},
{label: 'Email disabled', name: 'email-disabled'}
],
getColumnValue: (member) => {
if (member.emailSuppression && member.emailSuppression.suppressed) {
return {
text: 'Email disabled'
};
}
return member.newsletters.length > 0 ? {
text: 'Subscribed'
} : {
text: 'Unsubscribed'
};
}
};
}
if (newsletters.length > 1) {
// Disable
// Only show the filter for multiple newsletters if feature flag is enabled
return [];
}
export const SUBSCRIBED_FILTER = ({newsletters, group}) => {
return {
label: 'Newsletter subscription',
label: newsletters.length > 1 ? 'All newsletters' : 'Newsletter subscription',
name: 'subscribed',
columnLabel: 'Subscribed',
relationOptions: MATCH_RELATION_OPTIONS,
valueType: 'options',
group: group,
group: newsletters.length > 1 ? 'Newsletters' : group,
buildNqlFilter: (flt) => {
const relation = flt.relation;
const value = flt.value;
return (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? '(subscribed:true+email_disabled:0)'
: '(subscribed:false,email_disabled:1)';
if (value === 'email-disabled') {
if (relation === 'is') {
return '(email_disabled:1)';
}
return '(email_disabled:0)';
}
if (relation === 'is') {
if (value === 'subscribed') {
return '(subscribed:true+email_disabled:0)';
}
return '(subscribed:false+email_disabled:0)';
}
// relation === 'is-not'
if (value === 'subscribed') {
return '(subscribed:false,email_disabled:1)';
}
return '(subscribed:true,email_disabled:1)';
},
parseNqlFilter: (flt) => {
const comparator = flt.$and || flt.$or;
const comparator = flt.$and || flt.$or; // $or for legacy filter backwards compatibility
if (!comparator || comparator.length !== 2) {
const filter = flt;
if (filter && filter.email_disabled !== undefined) {
if (filter.email_disabled) {
return {
value: 'email-disabled',
relation: 'is'
};
}
return {
value: 'email-disabled',
relation: 'is-not'
};
}
return;
}
@ -128,31 +56,44 @@ export const SUBSCRIBED_FILTER = ({newsletters, feature, group}) => {
return;
}
const usedOr = flt.$or !== undefined;
const subscribed = comparator[0].subscribed;
if (usedOr) {
// Is not
return {
value: !subscribed ? 'subscribed' : 'unsubscribed',
relation: 'is-not'
};
}
return {
value: subscribed ? 'true' : 'false',
value: subscribed ? 'subscribed' : 'unsubscribed',
relation: 'is'
};
},
options: [
{label: 'Subscribed', name: 'true'},
{label: 'Unsubscribed', name: 'false'}
{label: newsletters.length > 1 ? 'Subscribed to at least one' : 'Subscribed', name: 'subscribed'},
{label: newsletters.length > 1 ? 'Unsubscribed from all' : 'Unsubscribed', name: 'unsubscribed'},
{label: 'Email disabled', name: 'email-disabled'}
],
getColumnValue: (member, flt) => {
const relation = flt.relation;
const value = flt.value;
getColumnValue: (member) => {
if (member.emailSuppression && member.emailSuppression.suppressed) {
return {
text: 'Email disabled'
};
}
return {
text: (relation === 'is' && value === 'true') || (relation === 'is-not' && value === 'false')
? 'Subscribed'
: 'Unsubscribed'
return member.newsletters.length > 0 ? {
text: 'Subscribed'
} : {
text: 'Unsubscribed'
};
}
};
};
export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
export const NEWSLETTERS_FILTERS = ({newsletters, group}) => {
if (newsletters.length <= 1) {
return [];
}
@ -210,12 +151,10 @@ export const NEWSLETTERS_FILTERS = ({newsletters, group, feature}) => {
const relation = flt.relation;
const value = flt.value;
if (feature.filterEmailDisabled) {
if (member.emailSuppression && member.emailSuppression.suppressed) {
return {
text: 'Email disabled'
};
}
if (member.emailSuppression && member.emailSuppression.suppressed) {
return {
text: 'Email disabled'
};
}
return {

View File

@ -1,39 +1,14 @@
<MultiList::List @model={{@list}} class="posts-list gh-list {{unless @model "no-posts"}} feature-memberAttribution" as |list| >
{{!-- always order as scheduled, draft, remainder --}}
{{#if (or @model.scheduledPosts (or @model.draftPosts @model.publishedAndSentPosts))}}
{{#if @model.scheduledPosts}}
{{#each @model.scheduledPosts as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}}
{{#each @model.draftPosts as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{#if (and @model.publishedAndSentPosts (and (or (not @model.scheduledPosts) @model.scheduledPosts.reachedInfinity) (or (not @model.draftPosts) @model.draftPosts.reachedInfinity)))}}
{{#each @model.publishedAndSentPosts as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{/each}}
{{/if}}
{{#each @model as |post|}}
<list.item @id={{post.id}} class="gh-posts-list-item-group">
<PostsList::ListItem
@post={{post}}
data-test-post-id={{post.id}}
/>
</list.item>
{{else}}
{{yield}}
{{/if}}
{{/each}}
</MultiList::List>
{{!-- The currently selected item or items are passed to the context menu --}}

View File

@ -1,8 +0,0 @@
<div class="gh-expandable-header">
<div>
<h4 class="gh-expandable-title">Counter</h4>
<p class="gh-expandable-description">Current counter value: <strong>{{this.counter}}</strong></p>
<p class="gh-expandable-description">This counter will reset when Ghost reboots.</p>
</div>
<button type="button" class="gh-btn" {{on "click" this.handleClick}} data-test-button="delete-all"><span>Add One</span></button>
</div>

View File

@ -1,31 +0,0 @@
import Component from '@glimmer/component';
import {action} from '@ember/object';
import {inject as service} from '@ember/service';
import {tracked} from '@glimmer/tracking';
export default class Websockets extends Component {
@service('socket-io') socketIOService;
constructor(...args) {
super(...args);
// initialize connection
// TODO: ensure this works with subdirectories
let origin = window.location.origin; // this gives us host:port
let socket = this.socketIOService.socketFor(origin);
// add listener
socket.on('addCount', (value) => {
this.counter = value;
});
}
// button counter
@tracked counter = 0;
// handle button/event
@action handleClick() {
let socket = this.socketIOService.socketFor(origin);
this.counter = 1 + this.counter;
socket.emit('addCount', this.counter);
}
}

View File

@ -62,7 +62,10 @@ export default class ResetController extends Controller.extend(ValidationEngine)
password_reset: [{newPassword, ne2Password, token}]
}
});
this.notifications.showAlert(resp.password_reset[0].message, {type: 'warn', delayed: true, key: 'password.reset'});
this.notifications.showNotification(
resp.password_reset[0].message,
{type: 'info', delayed: true, key: 'password.reset'}
);
this.session.authenticate('authenticator:cookie', email, newPassword);
return true;
} catch (error) {

View File

@ -25,6 +25,7 @@ export default class SigninController extends Controller.extend(ValidationEngine
@tracked submitting = false;
@tracked loggingIn = false;
@tracked flowNotification = '';
@tracked flowErrors = '';
@tracked passwordResetEmailSent = false;
@ -123,21 +124,19 @@ export default class SigninController extends Controller.extend(ValidationEngine
let notifications = this.notifications;
this.flowErrors = '';
this.flowNotification = '';
// This is a bit dirty, but there's no other way to ensure the properties are set as well as 'forgotPassword'
this.hasValidated.addObject('identification');
try {
yield this.validate({property: 'forgotPassword'});
yield this.ajax.post(forgottenUrl, {data: {password_reset: [{email}]}});
notifications.showAlert(
'Please check your email for instructions.',
{type: 'info', key: 'forgot-password.send.success'}
);
this.flowNotification = 'An email with password reset instructions has been sent.';
return true;
} catch (error) {
// ValidationEngine throws "undefined" for failed validation
if (!error) {
return this.flowErrors = 'We need your email address to reset your password!';
return this.flowErrors = 'We need your email address to reset your password.';
}
if (isVersionMismatchError(error)) {

View File

@ -1,13 +0,0 @@
import Controller from '@ember/controller';
/* eslint-disable ghost/ember/alias-model-in-controller */
import classic from 'ember-classic-decorator';
import {inject as service} from '@ember/service';
@classic
export default class WebsocketsController extends Controller {
@service feature;
init() {
super.init(...arguments);
}
}

View File

@ -60,9 +60,6 @@ Router.map(function () {
this.route('activitypub-x', {path: '/*sub'});
});
// testing websockets
this.route('websockets');
this.route('explore', function () {
// actual Ember route, not rendered in iframe
this.route('connect');

View File

@ -1,5 +1,4 @@
import AuthenticatedRoute from 'ghost-admin/routes/authenticated';
import RSVP from 'rsvp';
import {action} from '@ember/object';
import {assign} from '@ember/polyfills';
import {isBlank} from '@ember/utils';
@ -47,46 +46,36 @@ export default class PostsRoute extends AuthenticatedRoute {
totalPagesParam: 'meta.pagination.pages'
};
// type filters are actually mapping statuses
assign(filterParams, this._getTypeFilters(params.type));
if (params.type === 'featured') {
filterParams.featured = true;
}
// authors and contributors can only view their own posts
if (user.isAuthor) {
// authors can only view their own posts
filterParams.authors = user.slug;
} else if (user.isContributor) {
// Contributors can only view their own draft posts
filterParams.authors = user.slug;
// otherwise we need to filter by author if present
// filterParams.status = 'draft';
} else if (params.author) {
filterParams.authors = params.author;
}
let filter = this._filterString(filterParams);
if (!isBlank(filter)) {
queryParams.filter = filter;
}
if (!isBlank(params.order)) {
queryParams.order = params.order;
}
let perPage = this.perPage;
let paginationSettings = assign({perPage, startingPage: 1}, paginationParams, queryParams);
const filterStatuses = filterParams.status;
let models = {};
if (filterStatuses.includes('scheduled')) {
let scheduledPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: 'scheduled'})};
models.scheduledPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, scheduledPostsParams));
}
if (filterStatuses.includes('draft')) {
let draftPostsParams = {...queryParams, order: params.order || 'updated_at desc', filter: this._filterString({...filterParams, status: 'draft'})};
models.draftPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, draftPostsParams));
}
if (filterStatuses.includes('published') || filterStatuses.includes('sent')) {
let publishedAndSentPostsParams;
if (filterStatuses.includes('published') && filterStatuses.includes('sent')) {
publishedAndSentPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: '[published,sent]'})};
} else {
publishedAndSentPostsParams = {...queryParams, order: params.order || 'published_at desc', filter: this._filterString({...filterParams, status: filterStatuses.includes('published') ? 'published' : 'sent'})};
}
models.publishedAndSentPosts = this.infinity.model('post', assign({perPage, startingPage: 1}, paginationParams, publishedAndSentPostsParams));
}
return RSVP.hash(models);
return this.infinity.model(this.modelName, paginationSettings);
}
// trigger a background load of all tags and authors for use in filter dropdowns
@ -131,12 +120,6 @@ export default class PostsRoute extends AuthenticatedRoute {
};
}
/**
* Returns an object containing the status filter based on the given type.
*
* @param {string} type - The type of filter to generate (draft, published, scheduled, sent).
* @returns {Object} - An object containing the status filter.
*/
_getTypeFilters(type) {
let status = '[draft,scheduled,published,sent]';

View File

@ -1,18 +0,0 @@
import AuthenticatedRoute from './authenticated';
import {inject as service} from '@ember/service';
// need this to be authenticated
export default class WebsocketRoute extends AuthenticatedRoute {
@service session;
@service router;
beforeModel() {
super.beforeModel(...arguments);
const user = this.session.user;
if (!user.isAdmin) {
return this.router.transitionTo('settings-x.settings-x', `staff/${user.slug}`);
}
}
}

View File

@ -243,14 +243,16 @@ export default class DashboardStatsService extends Service {
return [];
}
const firstChartDay = moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
return this.memberAttributionStats.filter((stat) => {
if (this.chartDays === 'all') {
return true;
}
return stat.date >= moment().add(-this.chartDays, 'days').format('YYYY-MM-DD');
return stat.date >= firstChartDay;
}).reduce((acc, stat) => {
const statSource = stat.source ?? '';
const existingSource = acc.find(s => s.source === statSource);
const existingSource = acc.find(s => s.source.toLowerCase() === statSource.toLowerCase());
if (existingSource) {
existingSource.signups += stat.signups || 0;
existingSource.paidConversions += stat.paidConversions || 0;

View File

@ -63,7 +63,6 @@ export default class FeatureService extends Service {
@feature('lexicalMultiplayer') lexicalMultiplayer;
@feature('audienceFeedback') audienceFeedback;
@feature('webmentions') webmentions;
@feature('websockets') websockets;
@feature('stripeAutomaticTax') stripeAutomaticTax;
@feature('emailCustomization') emailCustomization;
@feature('i18n') i18n;
@ -77,13 +76,10 @@ export default class FeatureService extends Service {
@feature('tipsAndDonations') tipsAndDonations;
@feature('recommendations') recommendations;
@feature('lexicalIndicators') lexicalIndicators;
@feature('filterEmailDisabled') filterEmailDisabled;
@feature('adminXDemo') adminXDemo;
@feature('portalImprovements') portalImprovements;
@feature('ActivityPub') ActivityPub;
@feature('internalLinking') internalLinking;
@feature('editorExcerpt') editorExcerpt;
@feature('newsletterExcerpt') newsletterExcerpt;
@feature('contentVisibility') contentVisibility;
_user = null;

View File

@ -312,8 +312,8 @@
/* ---------------------------------------------------------- */
.gh-alert-black {
border-bottom: color-mod(var(--darkgrey) lightness(-10%)) 1px solid;
background: var(--darkgrey);
border-bottom: 1px solid var(--black);
background: var(--black);
color: #fff;
}
.gh-alert-black a {

View File

@ -271,7 +271,18 @@
.gh-setup .gh-flow-content .main-error {
margin-top: 16px;
color: var(--red);
font-size: 1.35rem;
font-size: 1.4rem;
line-height: 1.5;
text-align: center;
text-wrap: balance;
}
.gh-flow-content .main-notification,
.gh-setup .gh-flow-content .main-notification {
margin-top: 16px;
color: var(--black);
font-size: 1.4rem;
font-weight: 400;
line-height: 1.5;
text-align: center;
text-wrap: balance;

View File

@ -231,14 +231,14 @@ select.error {
.gh-input:focus,
.gh-input.focus {
outline: 0;
border-color: color-mod(var(--green)) !important;
border-color: var(--green);
box-shadow: inset 0 0 0 1px var(--green);
background: var(--white);
}
.error .gh-input:focus,
.error .gh-input.focus {
border-color: color-mod(var(--red)) !important;
border-color: var(--red);
box-shadow: inset 0 0 0 1px var(--red);
}

View File

@ -30,7 +30,7 @@
<section class="view-container content-list">
<PostsList::List
@model={{@model}}
@model={{this.postsInfinityModel}}
@list={{this.selectionList}}
>
<li class="no-posts-box" data-test-no-posts-box>
@ -51,26 +51,11 @@
</li>
</PostsList::List>
{{!-- only show one infinity loader wheel at a time - always order as scheduled, draft, remainder --}}
{{#if @model.scheduledPosts}}
<GhInfinityLoader
@infinityModel={{@model.scheduledPosts}}
@infinityModel={{this.postsInfinityModel}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
{{#if (and @model.draftPosts (or (not @model.scheduledPosts) (and @model.scheduledPosts @model.scheduledPosts.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.draftPosts}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
{{#if (and @model.publishedAndSentPosts (and (or (not @model.scheduledPosts) @model.scheduledPosts.reachedInfinity) (or (not @model.draftPosts) @model.draftPosts.reachedInfinity)))}}
<GhInfinityLoader
@infinityModel={{@model.publishedAndSentPosts}}
@scrollable=".gh-main"
@triggerOffset={{1000}} />
{{/if}}
</section>
{{outlet}}
</section>

View File

@ -73,7 +73,7 @@
data-test-button="sign-in" />
</form>
<p class="main-error">{{if this.flowErrors this.flowErrors}}&nbsp;</p>
<p class="{{if this.flowErrors "main-error" "main-notification"}}" data-test-flow-notification>{{if this.flowErrors this.flowErrors this.flowNotification}}&nbsp;</p>
{{/if}}
</section>
</div>

View File

@ -1,29 +0,0 @@
<section class="gh-canvas">
<GhCanvasHeader class="gh-canvas-header">
<div class="flex flex-column">
<h2 class="gh-canvas-title" data-test-screen-title>
Testing Websockets
</h2>
</div>
</GhCanvasHeader>
{{#if (feature 'websockets')}}
<section class="view-container settings-debug">
<p class="gh-box gh-box-tip">{{svg-jar "idea"}}This is a testing ground for new or experimental features. They
may change, break or inexplicably disappear at any time.</p>
<div class="gh-main-section">
<h4 class="gh-main-section-header small bn">Secrets</h4>
<div class="gh-expandable">
<div class="gh-expandable-block">
<Websockets />
</div>
</div>
</div>
</section>
{{else}}
<section class="view-container settings-debug">
<p class="gh-box gh-box-alert">{{svg-jar "warning-stroke"}}This is a testing ground for new or experimental features. You need developer experiments
enabled to see the content here.
</p>
</section>
{{/if}}
</section>

View File

@ -0,0 +1,98 @@
import moment from 'moment-timezone';
import {getNonDecimal, getSymbol} from 'ghost-admin/utils/currency';
export function getSubscriptionData(sub) {
const data = {
...sub,
attribution: {
...sub.attribution,
referrerSource: sub.attribution?.referrer_source || 'Unknown',
referrerMedium: sub.attribution?.referrer_medium || '-'
},
startDate: sub.start_date ? moment(sub.start_date).format('D MMM YYYY') : '-',
validUntil: validUntil(sub),
hasEnded: isCanceled(sub),
willEndSoon: isSetToCancel(sub),
cancellationReason: sub.cancellation_reason,
price: {
...sub.price,
currencySymbol: getSymbol(sub.price.currency),
nonDecimalAmount: getNonDecimal(sub.price.amount)
},
isComplimentary: isComplimentary(sub),
compExpiry: compExpiry(sub),
trialUntil: trialUntil(sub)
};
data.validityDetails = validityDetails(data);
return data;
}
export function validUntil(sub) {
// If a subscription has been canceled immediately, don't render the end of validity date
// Reason: we don't store the exact cancelation date in the subscription object
if (sub.status === 'canceled' && !sub.cancel_at_period_end) {
return '';
}
// Otherwise, show the current period end date
if (sub.current_period_end) {
return moment(sub.current_period_end).format('D MMM YYYY');
}
return '';
}
export function isActive(sub) {
return ['active', 'trialing', 'past_due', 'unpaid'].includes(sub.status);
}
export function isComplimentary(sub) {
return !sub.id;
}
export function isCanceled(sub) {
return sub.status === 'canceled';
}
export function isSetToCancel(sub) {
return sub.cancel_at_period_end && isActive(sub);
}
export function compExpiry(sub) {
if (!sub.id && sub.tier && sub.tier.expiry_at) {
return moment(sub.tier.expiry_at).utc().format('D MMM YYYY');
}
return undefined;
}
export function trialUntil(sub) {
const inTrialMode = sub.trial_end_at && moment(sub.trial_end_at).isAfter(new Date(), 'day');
if (inTrialMode) {
return moment(sub.trial_end_at).format('D MMM YYYY');
}
return undefined;
}
export function validityDetails(data) {
if (data.isComplimentary && data.compExpiry) {
return `Expires ${data.compExpiry}`;
}
if (data.hasEnded) {
return `Ended ${data.validUntil}`;
}
if (data.willEndSoon) {
return `Has access until ${data.validUntil}`;
}
if (data.trialUntil) {
return `Ends ${data.trialUntil}`;
}
return `Renews ${data.validUntil}`;
}

View File

@ -35,10 +35,6 @@ module.exports = function (environment) {
'ember-simple-auth': { },
'ember-websockets': {
socketIO: true
},
'@sentry/ember': {
disablePerformance: true,
sentry: {}

View File

@ -1,6 +1,6 @@
{
"name": "ghost-admin",
"version": "5.87.0",
"version": "5.87.1",
"description": "Ember.js admin client for Ghost",
"author": "Ghost Foundation",
"homepage": "http://ghost.org",
@ -49,7 +49,7 @@
"@tryghost/helpers": "1.1.90",
"@tryghost/kg-clean-basic-html": "4.1.1",
"@tryghost/kg-converters": "1.0.5",
"@tryghost/koenig-lexical": "1.3.2",
"@tryghost/koenig-lexical": "1.3.5",
"@tryghost/limit-service": "1.2.14",
"@tryghost/members-csv": "0.0.0",
"@tryghost/nql": "0.12.3",
@ -121,7 +121,6 @@
"ember-test-selectors": "6.0.0",
"ember-tooltips": "3.6.0",
"ember-truth-helpers": "3.1.1",
"ember-websockets": "10.2.1",
"eslint-plugin-babel": "5.3.1",
"flexsearch": "0.7.43",
"fs-extra": "11.2.0",
@ -172,7 +171,7 @@
"*.js": "eslint"
},
"dependencies": {
"jose": "4.15.7",
"jose": "4.15.9",
"path-browserify": "1.0.1",
"webpack": "5.92.1"
},
@ -206,4 +205,4 @@
}
}
}
}
}

View File

@ -1,6 +1,6 @@
import {authenticateSession, invalidateSession} from 'ember-simple-auth/test-support';
import {beforeEach, describe, it} from 'mocha';
import {blur, click, currentURL, fillIn, find, findAll, visit} from '@ember/test-helpers';
import {blur, click, currentURL, fillIn, find, findAll, settled, visit} from '@ember/test-helpers';
import {clickTrigger, selectChoose} from 'ember-power-select/test-support/helpers';
import {expect} from 'chai';
import {setupApplicationTest} from 'ember-mocha';
@ -41,7 +41,7 @@ describe('Acceptance: Content', function () {
return await authenticateSession();
});
it('displays and filters posts', async function () {
it.skip('displays and filters posts', async function () {
await visit('/posts');
// Not checking request here as it won't be the last request made
// Displays all posts + pages
@ -81,29 +81,38 @@ describe('Acceptance: Content', function () {
// show all posts
await selectChoose('[data-test-type-select]', 'All posts');
// Posts are ordered scheduled -> draft -> published/sent
// check API request is correct - we submit one request for scheduled, one for drafts, and one for published+sent
[lastRequest] = this.server.pretender.handledRequests.slice(-3);
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:scheduled');
[lastRequest] = this.server.pretender.handledRequests.slice(-2);
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:draft');
// API request is correct
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[published,sent]');
// check order display is correct
let postIds = findAll('[data-test-post-id]').map(el => el.getAttribute('data-test-post-id'));
expect(postIds, 'post order').to.deep.equal([scheduledPost.id, draftPost.id, publishedPost.id, authorPost.id]);
expect(lastRequest.queryParams.filter, '"all" request status filter').to.have.string('status:[draft,scheduled,published]');
// show all posts by editor
await selectChoose('[data-test-type-select]', 'Published posts');
await selectChoose('[data-test-author-select]', editor.name);
// API request is correct
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"editor" request status filter')
.to.have.string('status:published');
.to.have.string('status:[draft,scheduled,published]');
expect(lastRequest.queryParams.filter, '"editor" request filter param')
.to.have.string(`authors:${editor.slug}`);
// Post status is only visible when members is enabled
expect(find('[data-test-visibility-select]'), 'access dropdown before members enabled').to.not.exist;
let featureService = this.owner.lookup('service:feature');
featureService.set('members', true);
await settled();
expect(find('[data-test-visibility-select]'), 'access dropdown after members enabled').to.exist;
await selectChoose('[data-test-visibility-select]', 'Paid members-only');
[lastRequest] = this.server.pretender.handledRequests.slice(-1);
expect(lastRequest.queryParams.filter, '"visibility" request filter param')
.to.have.string('visibility:[paid,tiers]+status:[draft,scheduled,published]');
// Displays editor post
// TODO: implement "filter" param support and fix mirage post->author association
// expect(find('[data-test-post-id]').length, 'editor post count').to.equal(1);
// expect(find(`[data-test-post-id="${authorPost.id}"]`), 'author post').to.exist;
// TODO: test tags dropdown
});
// TODO: skipped due to consistently random failures on Travis

View File

@ -197,41 +197,10 @@ describe('Acceptance: Members filtering', function () {
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows').to.equal(1);
});
it('can filter by specific newsletter subscription', async function () {
// add some members to filters
const newsletter = this.server.create('newsletter', {status: 'active', slug: 'test-newsletter'});
this.server.createList('newsletter', 4);
this.server.createList('tier', 4);
this.server.createList('member', 4, {subscribed: false});
await visit('/members');
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
.to.equal(4);
await click('[data-test-button="members-filter-actions"]');
// make sure newsletters are in the filter dropdown
const newslettersCount = this.server.schema.newsletters.all().models.length;
let options = this.element.querySelectorAll('option');
let matchingOptions = [...options].filter(option => option.value.includes('newsletters.slug'));
expect(matchingOptions).to.have.length(newslettersCount);
await visit('/');
await visit('/members');
// add some members with tiers
const tier = this.server.create('tier');
const member = this.server.create('member', {tiers: [tier], subscribed: true});
member.update({newsletters: [newsletter]});
this.server.createList('member', 4, {subscribed: false});
await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:${newsletter.slug}`));
// only 1 member is subscribed so we should only see 1 row
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
.to.equal(1);
});
it('can filter by newsletter subscription', async function () {
// add some members to filter
it('can filter by newsletter subscription when there is only one newsletter', async function () {
// Create a single newsletter
this.server.createList('newsletter', 1);
// Add some members to filter
this.server.createList('member', 3, {subscribed: true, email_disabled: 0});
this.server.createList('member', 4, {subscribed: false, email_disabled: 0});
this.server.createList('member', 1, {subscribed: true, email_disabled: 1});
@ -255,18 +224,25 @@ describe('Acceptance: Members filtering', function () {
// has the right values
const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`);
expect(valueOptions).to.have.length(2);
expect(valueOptions[0]).to.have.value('true');
expect(valueOptions[1]).to.have.value('false');
expect(valueOptions).to.have.length(3);
expect(valueOptions[0]).to.have.value('subscribed');
expect(valueOptions[1]).to.have.value('unsubscribed');
expect(valueOptions[2]).to.have.value('email-disabled');
// applies default filter immediately
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true')
// applies default filter subscribed immediately
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed')
.to.equal(3);
// can change filter
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'false');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false')
.to.equal(5);
// can change filter to unsubscribed
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'unsubscribed');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed')
.to.equal(4);
expect(find('[data-test-table-column="subscribed"]')).to.exist;
// can change filter to email-disabled
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'email-disabled');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled')
.to.equal(1);
expect(find('[data-test-table-column="subscribed"]')).to.exist;
// can delete filter
@ -275,21 +251,99 @@ describe('Acceptance: Members filtering', function () {
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete')
.to.equal(8);
// Can set filter by path
// Can set filter to 'subscribed' by path
await visit('/');
await visit('/members?filter=' + encodeURIComponent('(subscribed:true+email_disabled:0)'));
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - true - from URL')
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed - from URL')
.to.equal(3);
await click('[data-test-button="members-filter-actions"]');
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('true');
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('subscribed');
// Can set filter by path
// Can set filter to 'unsubscribed' by path
await visit('/');
await visit('/members?filter=' + encodeURIComponent('(subscribed:false,email_disabled:1)'));
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - false - from URL')
.to.equal(5);
await visit('/members?filter=' + encodeURIComponent('(subscribed:false+email_disabled:0)'));
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed - from URL')
.to.equal(4);
await click('[data-test-button="members-filter-actions"]');
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('false');
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('unsubscribed');
// Can set filter to 'email-disabled' by path
await visit('/');
await visit('/members?filter=' + encodeURIComponent('(email_disabled:1)'));
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - email-disabled - from URL')
.to.equal(1);
await click('[data-test-button="members-filter-actions"]');
expect(find(`${filterSelector} [data-test-select="members-filter-value"]`)).to.have.value('email-disabled');
});
it('can filter by specific newsletter subscription when there are multiple newsletters', async function () {
// Create:
// - 1 subscribed member to newsletter
// - 1 subscribed member to newsletter with email disabled
// - 4 unsubscribed members
const newsletter = this.server.create('newsletter', {status: 'active', slug: 'test-newsletter'});
const tier = this.server.create('tier');
const subscribedMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 0});
subscribedMember.update({newsletters: [newsletter]});
const emailDisabledMember = this.server.create('member', {tiers: [tier], subscribed: true, email_disabled: 1});
emailDisabledMember.update({newsletters: [newsletter]});
this.server.createList('member', 4, {subscribed: false, email_disabled: 0});
// Test initial member count
await visit('/members');
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
.to.equal(6);
// Test newsletters options are in the filter dropdown
await click('[data-test-button="members-filter-actions"]');
const newslettersCount = this.server.schema.newsletters.all().models.length;
let options = this.element.querySelectorAll('option');
let matchingOptions = [...options].filter(option => option.value.includes('newsletters.slug'));
expect(matchingOptions).to.have.length(newslettersCount);
const filterSelector = `[data-test-members-filter="0"]`;
// Select first newsletter
await fillIn(`${filterSelector} [data-test-select="members-filter"]`, `newsletters.slug:${newsletter.slug}`);
// Test that the filter has the right operators
const operatorOptions = findAll(`${filterSelector} [data-test-select="members-filter-operator"] option`);
expect(operatorOptions[0]).to.have.value('is');
expect(operatorOptions[1]).to.have.value('is-not');
// Test that the filter has the right operators
const valueOptions = findAll(`${filterSelector} [data-test-select="members-filter-value"] option`);
expect(valueOptions[0]).to.have.value('true');
expect(valueOptions[1]).to.have.value('false');
// applies default filter subscribed immediately, and only count subscribed members without email disabled
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - subscribed')
.to.equal(1);
// can change filter to unsubscribed
await fillIn(`${filterSelector} [data-test-select="members-filter-value"]`, 'false');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows - unsubscribed')
.to.equal(5);
// can delete filter
await click('[data-test-delete-members-filter="0"]');
expect(findAll('[data-test-list="members-list-item"]').length, '# of filtered member rows after delete')
.to.equal(6);
// Can filter members subscribed to that newsletter by path
await visit('/');
await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:${newsletter.slug}+email_disabled:0`));
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
.to.equal(1);
// Can filter members unsubscribed to that newsletter by path
await visit('/');
await visit('/members?filter=' + encodeURIComponent(`newsletters.slug:-${newsletter.slug},email_disabled:1`));
expect(findAll('[data-test-list="members-list-item"]').length, '# of initial member rows')
.to.equal(5);
});
it('can filter by member status', async function () {

View File

@ -17,7 +17,7 @@ describe('Acceptance: Password Reset', function () {
await click('.forgotten-link');
// an alert with instructions is displayed
expect(findAll('.gh-alert-blue').length, 'alert count')
expect(findAll('[data-test-flow-notification]').length, 'alert count')
.to.equal(1);
});
@ -41,7 +41,7 @@ describe('Acceptance: Password Reset', function () {
// error message shown
expect(find('p.main-error').textContent.trim(), 'error message')
.to.equal('We need your email address to reset your password!');
.to.equal('We need your email address to reset your password.');
// invalid email provided
await fillIn('input[name="identification"]', 'test');
@ -61,7 +61,7 @@ describe('Acceptance: Password Reset', function () {
// error message
expect(find('p.main-error').textContent.trim(), 'error message')
.to.equal('We need your email address to reset your password!');
.to.equal('We need your email address to reset your password.');
// unknown email provided
await fillIn('input[name="identification"]', 'unknown@example.com');

View File

@ -45,11 +45,11 @@ describe('Integration: Component: gh-alert', function () {
this.message.type = 'warn';
await settled();
expect(alert, 'warn class is yellow').to.have.class('gh-alert-blue');
expect(alert, 'warn class is black').to.have.class('gh-alert-black');
this.message.type = 'info';
await settled();
expect(alert, 'info class is blue').to.have.class('gh-alert-blue');
expect(alert, 'info class is black').to.have.class('gh-alert-black');
});
it('closes notification through notifications service', async function () {

View File

@ -0,0 +1,341 @@
import moment from 'moment-timezone';
import {compExpiry, getSubscriptionData, isActive, isCanceled, isComplimentary, isSetToCancel, trialUntil, validUntil, validityDetails} from 'ghost-admin/utils/subscription-data';
import {describe, it} from 'mocha';
import {expect} from 'chai';
describe('Unit: Util: subscription-data', function () {
describe('validUntil', function () {
it('returns the end of the current billing period when the subscription is canceled at the end of the period', function () {
let sub = {
status: 'canceled',
cancel_at_period_end: true,
current_period_end: '2021-05-31'
};
expect(validUntil(sub)).to.equal('31 May 2021');
});
it('returns an empty string when the subscription is canceled immediately', function () {
let sub = {
status: 'canceled',
cancel_at_period_end: false,
current_period_end: '2021-05-31'
};
expect(validUntil(sub)).to.equal('');
});
it('returns the end of the current billing period when the subscription is active', function () {
let sub = {
status: 'active',
cancel_at_period_end: false,
current_period_end: '2021-05-31'
};
expect(validUntil(sub)).to.equal('31 May 2021');
});
it('returns the end of the current billing period when the subscription is in trial', function () {
let sub = {
status: 'trialing',
cancel_at_period_end: false,
current_period_end: '2021-05-31'
};
expect(validUntil(sub)).to.equal('31 May 2021');
});
it('returns the end of the current billing period when the subscription is past_due', function () {
let sub = {
status: 'past_due',
cancel_at_period_end: false,
current_period_end: '2021-05-31'
};
expect(validUntil(sub)).to.equal('31 May 2021');
});
it('returns the end of the current billing period when the subscription is unpaid', function () {
let sub = {
status: 'unpaid',
cancel_at_period_end: false,
current_period_end: '2021-05-31'
};
expect(validUntil(sub)).to.equal('31 May 2021');
});
// Extra data safety check, mainly for imported subscriptions
it('returns an empty string if the subcription is canceled immediately and has no current_period_start', function () {
let sub = {
status: 'canceled',
cancel_at_period_end: false
};
expect(validUntil(sub)).to.equal('');
});
// Extra data safety check, mainly for imported subscriptions
it('returns an empty string if the subscription has no current_period_end', function () {
let sub = {
status: 'active',
cancel_at_period_end: false
};
expect(validUntil(sub)).to.equal('');
});
});
describe('isActive', function () {
it('returns true for active subscriptions', function () {
let sub = {status: 'active'};
expect(isActive(sub)).to.be.true;
});
it('returns true for trialing subscriptions', function () {
let sub = {status: 'trialing'};
expect(isActive(sub)).to.be.true;
});
it('returns true for past_due subscriptions', function () {
let sub = {status: 'past_due'};
expect(isActive(sub)).to.be.true;
});
it('returns true for unpaid subscriptions', function () {
let sub = {status: 'unpaid'};
expect(isActive(sub)).to.be.true;
});
it('returns false for canceled subscriptions', function () {
let sub = {status: 'canceled'};
expect(isActive(sub)).to.be.false;
});
});
describe('isComplimentary', function () {
it('returns true for complimentary subscriptions', function () {
let sub = {id: null};
expect(isComplimentary(sub)).to.be.true;
});
it('returns false for paid subscriptions', function () {
let sub = {id: 'sub_123'};
expect(isComplimentary(sub)).to.be.false;
});
});
describe('isCanceled', function () {
it('returns true for canceled subscriptions', function () {
let sub = {status: 'canceled'};
expect(isCanceled(sub)).to.be.true;
});
it('returns false for active subscriptions', function () {
let sub = {status: 'active'};
expect(isCanceled(sub)).to.be.false;
});
});
describe('isSetToCancel', function () {
it('returns true for subscriptions set to cancel at the end of the period', function () {
let sub = {status: 'active', cancel_at_period_end: true};
expect(isSetToCancel(sub)).to.be.true;
});
it('returns false for canceled subscriptions', function () {
let sub = {status: 'canceled', cancel_at_period_end: true};
expect(isSetToCancel(sub)).to.be.false;
});
});
describe('trialUntil', function () {
it('returns the trial end date for subscriptions in trial', function () {
let sub = {status: 'trialing', trial_end_at: '2222-05-31'};
expect(trialUntil(sub)).to.equal('31 May 2222');
});
it('returns undefined for subscriptions not in trial', function () {
let sub = {status: 'active'};
expect(trialUntil(sub)).to.be.undefined;
});
});
describe('compExpiry', function () {
it('returns the complimentary expiry date for complimentary subscriptions', function () {
let sub = {id: null, tier: {expiry_at: moment.utc('2021-05-31').toISOString()}};
expect(compExpiry(sub)).to.equal('31 May 2021');
});
it('returns undefined for paid subscriptions', function () {
let sub = {id: 'sub_123'};
expect(compExpiry(sub)).to.be.undefined;
});
});
describe('validityDetails', function () {
it('returns "Expires {compExpiry}" for expired complimentary subscriptions', function () {
let data = {
isComplimentary: true,
compExpiry: '31 May 2021'
};
expect(validityDetails(data)).to.equal('Expires 31 May 2021');
});
it('returns "Ended {validUntil}" for canceled subscriptions', function () {
let data = {
hasEnded: true,
validUntil: '31 May 2021'
};
expect(validityDetails(data)).to.equal('Ended 31 May 2021');
});
it('returns "Has access until {validUntil}" for set to cancel subscriptions', function () {
let data = {
willEndSoon: true,
validUntil: '31 May 2021'
};
expect(validityDetails(data)).to.equal('Has access until 31 May 2021');
});
it('returns "Ends {validUntil}" for trial subscriptions', function () {
let data = {
trialUntil: '31 May 2021'
};
expect(validityDetails(data)).to.equal('Ends 31 May 2021');
});
it('returns "Renews {validUntil}" for active subscriptions', function () {
let data = {
validUntil: '31 May 2021'
};
expect(validityDetails(data)).to.equal('Renews 31 May 2021');
});
});
describe('getSubscriptionData', function () {
it('returns the correct data for an active subscription', function () {
let sub = {
id: 'defined',
status: 'active',
cancel_at_period_end: false,
current_period_end: '2021-05-31',
trial_end_at: null,
tier: null,
price: {
currency: 'usd',
amount: 5000
}
};
let data = getSubscriptionData(sub);
expect(data).to.include({
isComplimentary: false,
compExpiry: undefined,
hasEnded: false,
validUntil: '31 May 2021',
willEndSoon: false,
trialUntil: undefined,
validityDetails: 'Renews 31 May 2021'
});
});
it('returns the correct data for a trial subscription', function () {
let sub = {
id: 'defined',
status: 'trialing',
cancel_at_period_end: false,
current_period_end: '2222-05-31',
trial_end_at: '2222-05-31',
tier: null,
price: {
currency: 'usd',
amount: 5000
}
};
let data = getSubscriptionData(sub);
expect(data).to.include({
isComplimentary: false,
compExpiry: undefined,
hasEnded: false,
validUntil: '31 May 2222',
willEndSoon: false,
trialUntil: '31 May 2222',
validityDetails: 'Ends 31 May 2222'
});
});
it('returns the correct data for an immediately canceled subscription', function () {
let sub = {
id: 'defined',
status: 'canceled',
cancel_at_period_end: false,
current_period_end: '2021-05-31',
trial_end_at: null,
tier: null,
price: {
currency: 'usd',
amount: 5000
}
};
let data = getSubscriptionData(sub);
expect(data).to.include({
isComplimentary: false,
compExpiry: undefined,
hasEnded: true,
validUntil: '',
willEndSoon: false,
trialUntil: undefined,
validityDetails: 'Ended '
});
});
it('returns the correct data for a subscription set to cancel at the end of the period', function () {
let sub = {
id: 'defined',
status: 'active',
cancel_at_period_end: true,
current_period_end: '2021-05-31',
trial_end_at: null,
tier: null,
price: {
currency: 'usd',
amount: 5000
}
};
let data = getSubscriptionData(sub);
expect(data).to.include({
isComplimentary: false,
compExpiry: undefined,
hasEnded: false,
validUntil: '31 May 2021',
willEndSoon: true,
trialUntil: undefined,
validityDetails: 'Has access until 31 May 2021'
});
});
it('returns the correct data for a complimentary subscription', function () {
let sub = {
id: null,
status: 'active',
cancel_at_period_end: false,
current_period_end: '2021-05-31',
trial_end_at: null,
tier: {
expiry_at: moment.utc('2021-05-31').toISOString()
},
price: {
currency: 'usd',
amount: 0
}
};
let data = getSubscriptionData(sub);
expect(data).to.include({
isComplimentary: true,
compExpiry: '31 May 2021',
hasEnded: false,
validUntil: '31 May 2021',
willEndSoon: false,
trialUntil: undefined,
validityDetails: 'Expires 31 May 2021'
});
});
});
});

@ -1 +1 @@
Subproject commit 331257ea2976422fc4a7537bef07bb0c3ef2bd4d
Subproject commit f13641dd7e1d39884ebea73065db7b474ef0fd13

@ -1 +1 @@
Subproject commit 9a2f77a5b9ebe3c53194e3bc1187c0af7c7cc7ae
Subproject commit c5dd484544a3e4171d8b47c42f45b71e1ce6acc9

View File

@ -559,10 +559,6 @@ async function bootGhost({backend = true, frontend = true, server = true} = {})
// TODO: move this to the correct place once we figure out where that is
if (ghostServer) {
// NOTE: changes in this labs setting requires server reboot since we don't re-init services after changes a labs flag
const websockets = require('./server/services/websockets');
await websockets.init(ghostServer);
const lexicalMultiplayer = require('./server/services/lexical-multiplayer');
await lexicalMultiplayer.init(ghostServer);
await lexicalMultiplayer.enable();

View File

@ -76,6 +76,8 @@ const LIMIT = 15;
let sourceParam;
let utmSourceParam;
let utmMediumParam;
let referrerSource;
try {
// Fetch source/medium from query param
const url = new URL(window.location.href);
@ -83,11 +85,23 @@ const LIMIT = 15;
sourceParam = url.searchParams.get('source');
utmSourceParam = url.searchParams.get('utm_source');
utmMediumParam = url.searchParams.get('utm_medium');
referrerSource = refParam || sourceParam || utmSourceParam || null;
// if referrerSource is not set, check to see if the url contains a hash like ghost.org/#/portal/signup?ref=ghost and pull the ref from the hash
if (!referrerSource && url.hash && url.hash.includes('#/portal')) {
const hashUrl = new URL(window.location.href.replace('/#/portal', ''));
refParam = hashUrl.searchParams.get('ref');
sourceParam = hashUrl.searchParams.get('source');
utmSourceParam = hashUrl.searchParams.get('utm_source');
utmMediumParam = hashUrl.searchParams.get('utm_medium');
referrerSource = refParam || sourceParam || utmSourceParam || null;
}
} catch (e) {
console.error('[Member Attribution] Parsing referrer from querystring failed', e);
}
const referrerSource = refParam || sourceParam || utmSourceParam || null;
const referrerMedium = utmMediumParam || null;
const referrerUrl = window.document.referrer || null;

View File

@ -4,7 +4,7 @@ const debug = require('@tryghost/debug')('api:endpoints:utils:serializers:output
const messages = {
checkEmailForInstructions: 'Check your email for further instructions.',
passwordChanged: 'Password changed successfully.',
passwordChanged: 'Password updated',
invitationAccepted: 'Invitation accepted.'
};

View File

@ -211,6 +211,9 @@ class CustomRedirectsAPI {
*/
async setFromFilePath(filePath, ext = '.json') {
const redirectsFilePath = await this.getRedirectsFilePath();
const content = await readRedirectsFile(filePath);
const parsed = parseRedirectsFile(content, ext);
this.validate(parsed);
if (redirectsFilePath) {
const backupRedirectsPath = this.getBackupFilePath(redirectsFilePath);
@ -223,10 +226,6 @@ class CustomRedirectsAPI {
await fs.move(redirectsFilePath, backupRedirectsPath);
}
const content = await readRedirectsFile(filePath);
const parsed = parseRedirectsFile(content, ext);
this.validate(parsed);
if (ext === '.json') {
await fs.writeFile(this.createRedirectsFilePath('.json'), JSON.stringify(parsed), 'utf-8');
} else if (ext === '.yaml') {

View File

@ -1,6 +1,7 @@
const _ = require('lodash');
const tpl = require('@tryghost/tpl');
const errors = require('@tryghost/errors');
const {isSafePattern} = require('redos-detector');
const messages = {
redirectsWrongFormat: 'Incorrect redirects file format.',
@ -33,18 +34,35 @@ const validate = (redirects) => {
if (!redirect.from || !redirect.to) {
throw new errors.ValidationError({
message: tpl(messages.redirectsWrongFormat),
context: redirect,
help: tpl(messages.redirectsHelp)
});
}
// Ensure valid regex
try {
// each 'from' property should be a valid RegExp string
new RegExp(redirect.from);
} catch (error) {
throw new errors.ValidationError({
message: tpl(messages.invalidRedirectsFromRegex),
context: redirect,
errorDetails: {
redirect,
invalid: true
},
help: tpl(messages.redirectsHelp)
});
}
// Ensure safe regex
const analysis = isSafePattern(redirect.from);
if (analysis.safe === false) {
throw new errors.ValidationError({
message: tpl(messages.invalidRedirectsFromRegex),
errorDetails: {
redirect,
unsafe: true,
reason: analysis.error
},
help: tpl(messages.redirectsHelp)
});
}

View File

@ -1 +0,0 @@
module.exports = require('./service');

View File

@ -1,38 +0,0 @@
const {Server} = require('socket.io');
const debug = require('@tryghost/debug')('websockets');
const logging = require('@tryghost/logging');
const labs = require('../../../shared/labs');
module.exports = {
async init(ghostServer) {
debug(`[Websockets] Is labs set? ${labs.isSet('websockets')}`);
if (labs.isSet('websockets')) {
logging.info(`Starting websockets service`);
const io = new Server(ghostServer.httpServer);
let count = 0;
io.on(`connection`, (socket) => {
logging.info(`Websockets client connected (id: ${socket.id})`);
// on connect, send current value
socket.emit('addCount', count);
// listen to to changes in value from client
socket.on('addCount', () => {
count = count + 1;
debug(`[Websockets] received addCount from client, count is now ${count}`);
socket.broadcast.emit('addCount', count);
});
});
ghostServer.registerCleanupTask(async () => {
logging.warn(`Stopping websockets service`);
await new Promise((resolve) => {
io.close(resolve);
});
});
}
}
};

View File

@ -22,10 +22,7 @@ const GA_FEATURES = [
'signupForm',
'recommendations',
'listUnsubscribeHeader',
'filterEmailDisabled',
'newEmailAddresses',
'portalImprovements',
'newsletterExcerpt',
'internalLinking'
];
@ -45,7 +42,6 @@ const ALPHA_FEATURES = [
'NestPlayground',
'urlCache',
'lexicalMultiplayer',
'websockets',
'emailCustomization',
'mailEvents',
'collectionsCard',

View File

@ -1,6 +1,6 @@
{
"name": "ghost",
"version": "5.87.0",
"version": "5.87.1",
"description": "The professional publishing platform",
"author": "Ghost Foundation",
"homepage": "https://ghost.org",
@ -111,9 +111,9 @@
"@tryghost/kg-converters": "1.0.5",
"@tryghost/kg-default-atoms": "5.0.3",
"@tryghost/kg-default-cards": "10.0.6",
"@tryghost/kg-default-nodes": "1.1.5",
"@tryghost/kg-html-to-lexical": "1.1.5",
"@tryghost/kg-lexical-html-renderer": "1.1.6",
"@tryghost/kg-default-nodes": "1.1.7",
"@tryghost/kg-html-to-lexical": "1.1.8",
"@tryghost/kg-lexical-html-renderer": "1.1.8",
"@tryghost/kg-mobiledoc-html-renderer": "7.0.4",
"@tryghost/limit-service": "1.2.14",
"@tryghost/link-redirects": "0.0.0",
@ -133,7 +133,7 @@
"@tryghost/members-ssr": "0.0.0",
"@tryghost/members-stripe-service": "0.0.0",
"@tryghost/mentions-email-report": "0.0.0",
"@tryghost/metrics": "1.0.31",
"@tryghost/metrics": "1.0.34",
"@tryghost/milestones": "0.0.0",
"@tryghost/minifier": "0.0.0",
"@tryghost/model-to-domain-event-interceptor": "0.0.0",
@ -208,7 +208,7 @@
"keypair": "1.0.4",
"knex": "2.4.2",
"knex-migrator": "5.2.1",
"lib0": "0.2.88",
"lib0": "0.2.94",
"lodash": "4.17.21",
"luxon": "3.4.4",
"moment": "2.24.0",
@ -219,13 +219,13 @@
"node-jose": "2.2.0",
"path-match": "1.2.4",
"probe-image-size": "7.2.3",
"redos-detector": "5.1.0",
"rss": "1.2.2",
"sanitize-html": "2.13.0",
"semver": "7.6.2",
"socket.io": "4.7.5",
"stoppable": "1.1.0",
"uuid": "9.0.1",
"ws": "8.17.1",
"ws": "8.18.0",
"xml": "1.0.1",
"y-protocols": "1.0.6",
"yjs": "13.6.18"

View File

@ -1155,7 +1155,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": "4614",
"content-length": "4530",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -260,7 +260,7 @@ describe('Oembed API', function () {
const pageMock = nock('http://oembed.test.com')
.get('/')
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://oembed.test.com/my-embed"></head></html>');
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://oembed.test.com/my-embed"><title>Title</title></head></html>');
const oembedMock = nock('http://oembed.test.com')
.get('/my-embed')
@ -284,7 +284,7 @@ describe('Oembed API', function () {
it('fetches url and follows <link rel="alternate">', async function () {
const pageMock = nock('http://test.com')
.get('/')
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"></head></html>');
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"><title>Title</title></head></html>');
const oembedMock = nock('http://test.com')
.get('/oembed')
@ -307,7 +307,7 @@ describe('Oembed API', function () {
it('follows redirects when fetching <link rel="alternate">', async function () {
const pageMock = nock('http://test.com')
.get('/')
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"></head></html>');
.reply(200, '<html><head><link rel="alternate" type="application/json+oembed" href="http://test.com/oembed"><title>Title</title></head></html>');
const alternateRedirectMock = nock('http://test.com')
.get('/oembed')

View File

@ -221,7 +221,7 @@ describe('Members API', function () {
let canceledPaidMember;
it('Handles cancellation of paid subscriptions correctly', async function () {
it('Handles cancellation of paid subscriptions at the end of the billing cycle', async function () {
const customer_id = createStripeID('cust');
const subscription_id = createStripeID('sub');
@ -256,8 +256,143 @@ describe('Members API', function () {
// Create a new customer in Stripe
set(customer, {
id: customer_id,
name: 'Test Member',
email: 'cancel-paid-test@email.com',
name: 'Cancel me at the end of the billing cycle',
email: 'cancel-me-at-the-end-of-cycle@test.com',
subscriptions: {
type: 'list',
data: [subscription]
}
});
// Make sure this customer has a corresponding member in the database
// And all the subscriptions are setup correctly
const initialMember = await createMemberFromStripe();
assert.equal(initialMember.status, 'paid', 'The member initial status should be paid');
assert.equal(initialMember.tiers.length, 1, 'The member should have one tier');
should(initialMember.subscriptions).match([
{
status: 'active'
}
]);
// Check whether MRR and status has been set
await assertSubscription(initialMember.subscriptions[0].id, {
subscription_id: subscription.id,
status: 'active',
cancel_at_period_end: false,
plan_amount: 500,
plan_interval: 'month',
plan_currency: 'usd',
mrr: 500
});
// Set the subscription to cancel at the end of the period
set(subscription, {
...subscription,
status: 'active',
cancel_at_period_end: true,
metadata: {
cancellation_reason: 'I want to break free'
}
});
// Send the webhook call to announce the cancelation
const webhookPayload = JSON.stringify({
type: 'customer.subscription.updated',
data: {
object: subscription
}
});
const webhookSignature = stripe.webhooks.generateTestHeaderString({
payload: webhookPayload,
secret: process.env.WEBHOOK_SECRET
});
await membersAgent.post('/webhooks/stripe/')
.body(webhookPayload)
.header('content-type', 'application/json')
.header('stripe-signature', webhookSignature)
.expectStatus(200);
// Check that the subscription has been set to cancel and has saved the cancellation reason
const {body: body2} = await adminAgent.get('/members/' + initialMember.id + '/');
assert.equal(body2.members.length, 1, 'The member does not exist');
const updatedMember = body2.members[0];
should(updatedMember.subscriptions).match([
{
status: 'active',
cancel_at_period_end: true,
cancellation_reason: 'I want to break free'
}
]);
// Check whether MRR and cancel_at_period_end has been set
await assertSubscription(initialMember.subscriptions[0].id, {
subscription_id: subscription.id,
status: 'active',
cancel_at_period_end: true,
plan_amount: 500,
plan_interval: 'month',
plan_currency: 'usd',
mrr: 0
});
// Check that there is a canceled event
await assertMemberEvents({
eventType: 'MemberPaidSubscriptionEvent',
memberId: updatedMember.id,
asserts: [
{
type: 'created',
mrr_delta: 500
},
{
type: 'canceled',
mrr_delta: -500
}
]
});
canceledPaidMember = updatedMember;
});
it('Handles immediate cancellation of paid subscriptions', async function () {
const customer_id = createStripeID('cust');
const subscription_id = createStripeID('sub');
// Create a new subscription in Stripe
set(subscription, {
id: subscription_id,
customer: customer_id,
status: 'active',
items: {
type: 'list',
data: [{
id: 'item_123',
price: {
id: 'price_123',
product: 'product_123',
active: true,
nickname: 'Monthly',
currency: 'usd',
recurring: {
interval: 'month'
},
unit_amount: 500,
type: 'recurring'
}
}]
},
start_date: Date.now() / 1000,
current_period_end: Date.now() / 1000 + (60 * 60 * 24 * 31),
cancel_at_period_end: false
});
// Create a new customer in Stripe
set(customer, {
id: customer_id,
name: 'Cancel me now',
email: 'cancel-me-immediately@test.com',
subscriptions: {
type: 'list',
data: [subscription]
@ -289,12 +424,15 @@ describe('Members API', function () {
// Cancel the previously created subscription in Stripe
set(subscription, {
...subscription,
status: 'canceled'
status: 'canceled',
cancellation_details: {
reason: 'payment_failed'
}
});
// Send the webhook call to announce the cancelation
const webhookPayload = JSON.stringify({
type: 'customer.subscription.updated',
type: 'customer.subscription.deleted',
data: {
object: subscription
}
@ -318,7 +456,8 @@ describe('Members API', function () {
assert.equal(updatedMember.tiers.length, 0, 'The member should have no products');
should(updatedMember.subscriptions).match([
{
status: 'canceled'
status: 'canceled',
cancellation_reason: 'Payment failed'
}
]);

View File

@ -400,5 +400,25 @@ describe('Member Attribution Service', function () {
referrerUrl: null
}));
});
it('resolves Portal signup URLs', async function () {
// NOTE: We cannot test the actual hash URL here; the attribution below is what is receieved when navigating to /#/portal/signup?ref=ghost
// TODO: We don't appear to have tests for parsing URLs for params.
const attribution = await memberAttributionService.service.getAttribution([
{
path: '/',
time: Date.now(),
referrerSource: 'casper'
}
]);
attribution.should.match(({
id: null,
url: '/',
type: 'url',
referrerSource: 'casper',
referrerMedium: null,
referrerUrl: null
}));
});
});
});
});

View File

@ -754,7 +754,7 @@ exports[`Authentication API Password reset reset password 1: [body] 1`] = `
Object {
"password_reset": Array [
Object {
"message": "Password changed successfully.",
"message": "Password updated",
},
],
}
@ -764,7 +764,7 @@ exports[`Authentication API Password reset reset password 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": "65",
"content-length": "51",
"content-type": "application/json; charset=utf-8",
"content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/,
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,

View File

@ -259,5 +259,48 @@ describe('UNIT: redirects CustomRedirectsAPI class', function () {
// two redirects in total
redirectManager.addRedirect.calledTwice.should.be.true();
});
it('does not create a backup file from a bad redirect yaml file', async function () {
const incomingFilePath = path.join(__dirname, '/invalid/path/redirects_incoming.yaml');
const backupFilePath = path.join(basePath, 'backup.yaml');
const invalidYaml = `
301:
/my-old-blog-post/: /revamped-url/
/my-old-blog-post/: /revamped-url/
302:
/another-old-blog-post/: /hello-there/
`;
// redirects.json file already exits
fs.pathExists.withArgs(`${basePath}redirects.json`).resolves(false);
fs.pathExists.withArgs(`${basePath}redirects.yaml`).resolves(true);
// incoming redirects file
fs.readFile.withArgs(incomingFilePath, 'utf-8').resolves(invalidYaml);
// backup file DOES not exists yet
fs.pathExists.withArgs(backupFilePath).resolves(false);
// should not be called
fs.unlink.withArgs(backupFilePath).resolves(false);
fs.move.withArgs(`${basePath}redirects.yaml`, backupFilePath).resolves(true);
customRedirectsAPI = new CustomRedirectsAPI({
basePath,
redirectManager,
getBackupFilePath: () => backupFilePath,
validate: () => {}
});
try {
await customRedirectsAPI.setFromFilePath(incomingFilePath, '.yaml');
should.fail('setFromFilePath did not throw');
} catch (err) {
should.exist(err);
err.errorType.should.eql('BadRequestError');
}
fs.unlink.called.should.not.be.true();
fs.move.called.should.not.be.true();
});
});
});

View File

@ -39,6 +39,26 @@ describe('UNIT: custom redirects validation', function () {
should.fail('should have thrown');
} catch (err) {
err.message.should.equal('Incorrect RegEx in redirects file.');
err.errorDetails.redirect.should.equal(config[0]);
err.errorDetails.invalid.should.be.true();
}
});
it('throws for an invalid redirects config having unsafe RegExp in from field', function () {
const config = [{
permanent: true,
from: '^\/episodes\/([a-z0-9-]+)+\/$', // Unsafe due to the surplus + at the end causing infinite backtracking
to: '/'
}];
try {
validate(config);
should.fail('should have thrown');
} catch (err) {
err.message.should.equal('Incorrect RegEx in redirects file.');
err.errorDetails.redirect.should.equal(config[0]);
err.errorDetails.unsafe.should.be.true();
err.errorDetails.reason.should.equal('hitMaxBacktracks');
}
});

View File

@ -772,13 +772,8 @@ class EmailRenderer {
registerHelpers(this.#handlebars, labs);
// Partials
if (this.#labs.isSet('emailCustomization')) {
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8');
this.#handlebars.registerPartial('styles', cssPartialSource);
} else {
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles-old.hbs`), 'utf8');
this.#handlebars.registerPartial('styles', cssPartialSource);
}
const cssPartialSource = await fs.readFile(path.join(__dirname, './email-templates/partials/', `styles.hbs`), 'utf8');
this.#handlebars.registerPartial('styles', cssPartialSource);
const paywallPartial = await fs.readFile(path.join(__dirname, './email-templates/partials/', `paywall.hbs`), 'utf8');
this.#handlebars.registerPartial('paywall', paywallPartial);
@ -793,13 +788,9 @@ class EmailRenderer {
this.#handlebars.registerPartial('latestPosts', latestPostsPartial);
// Actual template
if (this.#labs.isSet('emailCustomization')) {
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template.hbs`), 'utf8');
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
} else {
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template-old.hbs`), 'utf8');
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
}
const htmlTemplateSource = await fs.readFile(path.join(__dirname, './email-templates/', `template.hbs`), 'utf8');
this.#renderTemplate = this.#handlebars.compile(Buffer.from(htmlTemplateSource).toString());
return this.#renderTemplate(data);
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -1,251 +0,0 @@
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<!--[if mso]><xml><o:OfficeDocumentSettings><o:PixelsPerInch>96</o:PixelsPerInch><o:AllowPNG/></o:OfficeDocumentSettings></xml><![endif]-->
<title>{{post.title}}</title>
{{>styles}}
</head>
<body>
<span class="preheader">{{preheader}}</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" width="100%">
<!-- Outlook doesn't respect max-width so we need an extra centered table -->
<!--[if mso]>
<tr>
<td>
<center>
<table border="0" cellpadding="0" cellspacing="0" width="600">
<![endif]-->
<tr>
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
{{#if headerImage}}
<tr class="header-image-row">
<td class="header-image" width="100%" align="center">
<a href="{{site.url}}">
<img
src="{{headerImage}}"
{{#if headerImageWidth}}
width="{{headerImageWidth}}"
{{/if}}
>
</a>
</td>
</tr>
{{/if}}
{{#if (or showHeaderIcon showHeaderTitle showHeaderName) }}
<tr class="site-info-row">
<td class="site-info" width="100%" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
{{#if (and showHeaderIcon site.iconUrl) }}
<tr>
<td class="site-icon"><a href="{{site.url}}"><img src="{{site.iconUrl}}" alt="{{site.title}}" border="0" width="48" height="48"></a></td>
</tr>
{{/if}}
{{#if showHeaderTitle }}
<tr>
<td class="site-url {{#unless showHeaderName}}site-url-bottom-padding{{/unless}}"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-title">{{site.title}}</a></div></td>
</tr>
{{/if}}
{{#if (and showHeaderName showHeaderTitle) }}
<tr>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-subtitle">{{newsletter.name}}</a></div></td>
</tr>
{{/if}}
{{#if (and showHeaderName (not showHeaderTitle)) }}
<tr>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="{{site.url}}" class="site-title">{{newsletter.name}}</a></div></td>
</tr>
{{/if}}
</table>
</td>
</tr>
{{/if}}
{{#if newsletter.showPostTitleSection}}
<tr>
<td class="{{classes.title}}">
<a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
</td>
</tr>
{{#hasFeature 'newsletterExcerpt'}}
{{#if (and newsletter.showExcerpt post.customExcerpt)}}
<tr>
<td class="post-excerpt-wrapper" style="width: 100%">
<p class="{{classes.excerpt}}">{{post.customExcerpt}}</p>
</td>
</tr>
{{/if}}
{{/hasFeature}}
<tr>
<td style="width: 100%">
<table class="post-meta-wrapper" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-bottom: 32px;">
<tr>
<td height="20" class="{{classes.meta}}" style="padding: 0;">
By {{post.authors}} • <span class="post-meta-date">{{post.publishedAt}} </span>
</td>
<td class="{{classes.meta}} view-online desktop">
<a href="{{post.url}}" class="view-online-link">View in browser</a>
</td>
</tr>
<tr class="{{classes.meta}} view-online-mobile">
<td height="20" class="view-online">
<a href="{{post.url}}" class="view-online-link">View in browser</a>
</td>
</tr>
</table>
</td>
</tr>
{{/if}}
{{#if showFeatureImage }}
<tr class="feature-image-row">
<td class="feature-image
{{#if post.feature_image_caption }}
feature-image-with-caption
{{/if}}
" align="center"><img
src="{{post.feature_image}}"
{{#if post.feature_image_width }}
width="{{post.feature_image_width}}"
{{/if}}
{{#if post.feature_image_alt }}
alt="{{post.feature_image_alt}}"
{{/if}}
></td>
</tr>
{{#if post.feature_image_caption }}
<tr>
<td align="center">
<div class="feature-image-caption">
{{{post.feature_image_caption}}}
</div>
</td>
</tr>
{{/if}}
{{/if}}
<tr class="post-content-row">
<td class="{{classes.body}}">
<!-- POST CONTENT START -->
{{{html}}}
<!-- POST CONTENT END -->
{{#if paywall}}
{{>paywall}}
{{/if}}
</td>
</tr>
</table>
</td>
</tr>
<!-- END MAIN CONTENT AREA -->
{{#if (or feedbackButtons newsletter.showCommentCta) }}
<tr>
<td dir="ltr" width="100%" style="background-color: #ffffff; text-align: center; padding: 32px 0 24px; border-bottom: 1px solid #e5eff5;" align="center">
<table class="feedback-buttons" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
{{#if feedbackButtons }}
{{> feedbackButton feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' iconUrl="https://static.ghost.org/v5.0.0/images/more-like-this.png" width="145" height="36" }}
{{> feedbackButton feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' iconUrl="https://static.ghost.org/v5.0.0/images/less-like-this.png" width="142" height="36" }}
{{/if}}
{{#if newsletter.showCommentCta}}
{{> feedbackButton href=post.commentUrl buttonText='Comment' iconUrl="https://static.ghost.org/v5.0.0/images/comment.png" width="122" height="36" }}
{{/if}}
</tr>
</table>
<table class="feedback-buttons-mobile" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
{{#if feedbackButtons }}
{{> feedbackButtonMobile feedbackButtons href=feedbackButtons.likeHref buttonText='More like this' iconUrl="https://static.ghost.org/v5.0.0/images/more-like-this-mobile.png" width="42" height="42" }}
{{> feedbackButtonMobile feedbackButtons href=feedbackButtons.dislikeHref buttonText='Less like this' iconUrl="https://static.ghost.org/v5.0.0/images/less-like-this-mobile.png" width="42" height="42" }}
{{/if}}
{{#if newsletter.showCommentCta}}
{{> feedbackButtonMobile href=post.commentUrl buttonText='Comment' iconUrl="https://static.ghost.org/v5.0.0/images/comment-mobile.png" width="42" height="42" }}
{{/if}}
</tr>
</table>
</td>
</tr>
{{/if}}
{{#if latestPosts.length}}
<tr>
<td style="padding: 24px 0; border-bottom: 1px solid #e5eff5;">
<h3 class="latest-posts-header">Keep reading</h3>
{{> latestPosts}}
</td>
</tr>
{{/if}}
{{#if newsletter.showSubscriptionDetails}}
<tr>
<td class="subscription-box">
<h3>Subscription details</h3>
<p style="margin-bottom: 16px;">
<span>You are receiving this because you are a <strong>%%{status}%% subscriber</strong> to {{site.title}}.</span> %%{status_text}%%
</p>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="subscription-details">
<p class="%%{name_class}%%">Name: %%{name, "not provided"}%%</p>
<p>Email: <a href="#">%%{email}%%</a></p>
<p>Member since: %%{created_at}%%</p>
</td>
<td align="right" valign="bottom" class="manage-subscription">
<a href="%%{manage_account_url}%%"> Manage subscription &rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
{{/if}}
<tr>
<td class="wrapper" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-top: 40px; padding-bottom: 30px;">
{{#if footerContent }}
<tr><td class="footer">{{{footerContent}}}</td></tr>
{{/if}}
<tr>
<td class="footer">{{site.title}} &copy; {{year}} <a href="%%{unsubscribe_url}%%">Unsubscribe</a></td>
</tr>
{{#if showBadge }}
<tr>
<td class="footer-powered"><a href="https://ghost.org/?via=pbg-newsletter"><img src="https://static.ghost.org/v4.0.0/images/powered.png" border="0" width="142" height="30" class="gh-powered" alt="Powered by Ghost"></a></td>
</tr>
{{/if}}
</table>
</td>
</tr>
</table>
<!-- END CENTERED WHITE CONTAINER -->
</div>
</td>
<td>&nbsp;</td>
</tr>
<!--[if mso]>
</table>
</center>
</td>
</tr>
<![endif]-->
</table>
</body>
</html>

View File

@ -22,7 +22,7 @@
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="20" class="main" width="100%">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
<!-- START MAIN CONTENT AREA -->
<tr>
@ -44,12 +44,12 @@
{{/if}}
{{#if (or showHeaderIcon showHeaderTitle showHeaderName) }}
<tr>
<td class="{{#if (and newsletter.showPostTitleSection showHeaderTitle) }}site-info-bordered{{else}}site-info{{/if}}" width="100%" align="center">
<tr class="site-info-row">
<td class="site-info" width="100%" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
{{#if (and showHeaderIcon site.iconUrl) }}
<tr>
<td class="site-icon"><a href="{{site.url}}"><img src="{{site.iconUrl}}" alt="{{site.title}}" border="0"></a></td>
<td class="site-icon"><a href="{{site.url}}"><img src="{{site.iconUrl}}" alt="{{site.title}}" border="0" width="48" height="48"></a></td>
</tr>
{{/if}}
{{#if showHeaderTitle }}
@ -79,9 +79,16 @@
<a href="{{post.url}}" class="{{classes.titleLink}}">{{post.title}}</a>
</td>
</tr>
{{#if (and newsletter.showExcerpt post.customExcerpt)}}
<tr>
<td class="post-excerpt-wrapper" style="width: 100%">
<p class="{{classes.excerpt}}">{{post.customExcerpt}}</p>
</td>
</tr>
{{/if}}
<tr>
<td style="width: 100%">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-bottom: 48px;">
<table class="post-meta-wrapper" role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-bottom: 32px;">
<tr>
<td height="20" class="{{classes.meta}}" style="padding: 0;">
By {{post.authors}} • <span class="post-meta-date">{{post.publishedAt}} </span>
@ -116,9 +123,14 @@
{{/if}}
></td>
</tr>
{{#if post.feature_image_caption }}
{{#if post.feature_image_caption }}
<tr>
<td class="feature-image-caption" align="center">{{{post.feature_image_caption}}}</td>
<td align="center">
<div class="feature-image-caption">
{{{post.feature_image_caption}}}
</div>
</td>
</tr>
{{/if}}
{{/if}}
@ -141,7 +153,7 @@
{{#if (or feedbackButtons newsletter.showCommentCta) }}
<tr>
<td dir="ltr" width="100%" style="background-color: {{backgroundColor}}; text-align: center; padding: 32px 0 24px; border-bottom: 1px solid #e5eff5; border-bottom: 1px solid {{secondaryBorderColor}};" align="center">
<td dir="ltr" width="100%" style="background-color: #ffffff; text-align: center; padding: 32px 0 24px; border-bottom: 1px solid #e5eff5;" align="center">
<table class="feedback-buttons" role="presentation" border="0" cellpadding="0" cellspacing="0">
<tr>
{{#if feedbackButtons }}
@ -170,7 +182,7 @@
{{#if latestPosts.length}}
<tr>
<td style="padding: 24px 0; border-bottom: 1px solid #e5eff5; border-bottom: 1px solid {{secondaryBorderColor}};">
<td style="padding: 24px 0; border-bottom: 1px solid #e5eff5;">
<h3 class="latest-posts-header">Keep reading</h3>
{{> latestPosts}}
</td>

View File

@ -1770,12 +1770,6 @@ describe('Email renderer', function () {
});
describe('show excerpt', function () {
beforeEach(function () {
labsEnabled = {
newsletterExcerpt: true
};
});
it('is rendered when enabled and customExcerpt is present', async function () {
const post = createModel(Object.assign({}, basePost, {custom_excerpt: 'This is an excerpt'}));
const newsletter = createModel({

View File

@ -20,23 +20,22 @@
"build"
],
"devDependencies": {
"@nestjs/testing": "10.3.9",
"@nestjs/testing": "10.3.10",
"@types/node": "20.14.8",
"@types/sinon": "^17.0.3",
"@types/supertest": "^6.0.2",
"c8": "8.0.1",
"mocha": "10.2.0",
"nock": "^14.0.0-beta.6",
"reflect-metadata": "0.1.13",
"sinon": "^17.0.1",
"supertest": "^7.0.0",
"ts-node": "10.9.2",
"typescript": "5.4.5"
},
"dependencies": {
"@nestjs/common": "10.3.9",
"@nestjs/core": "10.3.9",
"@nestjs/platform-express": "10.3.9",
"@nestjs/common": "10.3.10",
"@nestjs/core": "10.3.10",
"@nestjs/platform-express": "10.3.10",
"@tryghost/errors": "1.3.2",
"bson-objectid": "2.0.4",
"express": "4.19.2",

View File

@ -105,9 +105,6 @@ class LinkClickTrackingService {
* @throws {errors.BadRequestError}
*/
#parseLinkFilter(filter) {
// decode filter to manage any encoded uri components
filter = decodeURIComponent(filter);
try {
const filterJson = nql(filter).parse();
const postId = filterJson?.$and?.[0]?.post_id;

View File

@ -26,7 +26,7 @@
"dependencies": {
"@tryghost/debug": "0.1.30",
"@tryghost/logging": "2.4.15",
"@tryghost/metrics": "1.0.31",
"@tryghost/metrics": "1.0.34",
"form-data": "4.0.0",
"lodash": "4.17.21",
"mailgun.js": "10.2.1"

View File

@ -992,7 +992,7 @@ module.exports = class MemberRepository {
subscription_id: subscription.id,
status: subscription.status,
cancel_at_period_end: subscription.cancel_at_period_end,
cancellation_reason: subscription.metadata && subscription.metadata.cancellation_reason || null,
cancellation_reason: this.getCancellationReason(subscription),
current_period_end: new Date(subscription.current_period_end * 1000),
start_date: new Date(subscription.start_date * 1000),
default_payment_card_last4: paymentMethod && paymentMethod.card && paymentMethod.card.last4 || null,
@ -1301,6 +1301,19 @@ module.exports = class MemberRepository {
}
}
getCancellationReason(subscription) {
// Case: manual cancellation in Portal
if (subscription.metadata && subscription.metadata.cancellation_reason) {
return subscription.metadata.cancellation_reason;
// Case: Automatic cancellation due to several payment failures
} else if (subscription.cancellation_details && subscription.cancellation_details.reason && subscription.cancellation_details.reason === 'payment_failed') {
return 'Payment failed';
}
return null;
}
async getSubscription(data, options) {
if (!this._stripeAPIService.configured) {
throw new errors.BadRequestError({message: tpl(messages.noStripeConnection, {action: 'get Stripe Subscription'})});

View File

@ -28,7 +28,7 @@
"@tryghost/errors": "1.3.2",
"@tryghost/logging": "2.4.15",
"@tryghost/members-csv": "0.0.0",
"@tryghost/metrics": "1.0.31",
"@tryghost/metrics": "1.0.34",
"@tryghost/tpl": "0.1.30",
"moment-timezone": "0.5.23"
}

View File

@ -340,6 +340,11 @@ class OEmbedService {
];
const oembed = _.pick(body, knownFields);
// Fallback to bookmark if it's a link type
if (oembed.type === 'link') {
return;
}
// ensure we have required data for certain types
if (oembed.type === 'photo' && !oembed.url) {
return;

View File

@ -30,15 +30,15 @@
"cheerio": "0.22.0",
"iconv-lite": "0.6.3",
"lodash": "4.17.21",
"metascraper": "5.41.0",
"metascraper-author": "5.42.5",
"metascraper-description": "5.42.0",
"metascraper-image": "5.42.0",
"metascraper-logo": "5.42.0",
"metascraper": "5.45.15",
"metascraper-author": "5.45.10",
"metascraper-description": "5.45.10",
"metascraper-image": "5.45.10",
"metascraper-logo": "5.45.10",
"metascraper-logo-favicon": "5.42.0",
"metascraper-publisher": "5.42.0",
"metascraper-title": "5.42.0",
"metascraper-url": "5.40.0",
"metascraper-publisher": "5.45.10",
"metascraper-title": "5.45.10",
"metascraper-url": "5.45.10",
"tough-cookie": "4.1.4"
}
}

View File

@ -146,5 +146,31 @@ describe('oembed-service', function () {
assert.equal(response.url, 'https://www.example.com');
assert.equal(response.metadata.title, 'Example');
});
it('should return a bookmark response when the oembed endpoint returns a link type', async function () {
nock('https://www.example.com')
.get('/')
.query(true)
.reply(200, `<html><head><link type="application/json+oembed" href="https://www.example.com/oembed"><title>Example</title></head></html>`);
nock('https://www.example.com')
.get('/oembed')
.query(true)
.reply(200, {
type: 'link',
version: '1.0',
title: 'Test Title',
author_name: 'Test Author',
author_url: 'https://example.com/user/testauthor',
url: 'https://www.example.com'
});
const response = await oembedService.fetchOembedDataFromUrl('https://www.example.com');
assert.equal(response.version, '1.0');
assert.equal(response.type, 'bookmark');
assert.equal(response.url, 'https://www.example.com');
assert.equal(response.metadata.title, 'Example');
});
});
});

1841
yarn.lock

File diff suppressed because it is too large Load Diff