diff --git a/apps/comments-ui/vite.config.ts b/apps/comments-ui/vite.config.ts
index 6819b4b8eb1..d725139a372 100644
--- a/apps/comments-ui/vite.config.ts
+++ b/apps/comments-ui/vite.config.ts
@@ -21,7 +21,8 @@ export default (function viteConfig() {
},
preview: {
host: '0.0.0.0',
- port: 7173
+ port: 7173,
+ cors: true
},
server: {
port: 5368
diff --git a/apps/portal/vite.config.js b/apps/portal/vite.config.js
index 79d34a39601..96e6e102bf4 100644
--- a/apps/portal/vite.config.js
+++ b/apps/portal/vite.config.js
@@ -22,7 +22,8 @@ export default defineConfig((config) => {
},
preview: {
host: '0.0.0.0',
- port: 4175
+ port: 4175,
+ cors: true
},
server: {
port: 5368
diff --git a/apps/shade/package.json b/apps/shade/package.json
index b2fbbb2c058..32c01162f2b 100644
--- a/apps/shade/package.json
+++ b/apps/shade/package.json
@@ -40,7 +40,7 @@
"@testing-library/react": "14.3.1",
"@testing-library/react-hooks": "8.0.1",
"@types/lodash-es": "4.17.12",
- "@types/node": "22.15.3",
+ "@types/node": "22.15.7",
"@vitejs/plugin-react": "4.4.1",
"c8": "8.0.1",
"chai": "4.5.0",
diff --git a/compose.yml b/compose.yml
index 91ed95e9ab3..5162209b9b9 100644
--- a/compose.yml
+++ b/compose.yml
@@ -1,6 +1,62 @@
name: ghost
+
+# Template to share volumes and environment variable between all services running the same base image
+x-service-template: &service-template
+ volumes:
+ - .:/home/ghost
+ - ${SSH_AUTH_SOCK}:/ssh-agent
+ - ${HOME}/.gitconfig:/root/.gitconfig:ro
+ - node_modules_ghost_root:/home/ghost/node_modules:delegated
+ - node_modules_ghost_admin:/home/ghost/ghost/admin/node_modules:delegated
+ - node_modules_ghost_api-framework:/home/ghost/ghost/api-framework/node_modules:delegated
+ - node_modules_ghost_constants:/home/ghost/ghost/constants/node_modules:delegated
+ - node_modules_ghost_core:/home/ghost/ghost/core/node_modules:delegated
+ - node_modules_ghost_custom-fonts:/home/ghost/ghost/custom-fonts/node_modules:delegated
+ - node_modules_ghost_domain-events:/home/ghost/ghost/domain-events/node_modules:delegated
+ - node_modules_ghost_donations:/home/ghost/ghost/donations/node_modules:delegated
+ - node_modules_ghost_email-addresses:/home/ghost/ghost/email-addresses/node_modules:delegated
+ - node_modules_ghost_email-service:/home/ghost/ghost/email-service/node_modules:delegated
+ - node_modules_ghost_html-to-plaintext:/home/ghost/ghost/html-to-plaintext/node_modules:delegated
+ - node_modules_ghost_i18n:/home/ghost/ghost/i18n/node_modules:delegated
+ - node_modules_ghost_job-manager:/home/ghost/ghost/job-manager/node_modules:delegated
+ - node_modules_ghost_link-replacer:/home/ghost/ghost/link-replacer/node_modules:delegated
+ - node_modules_ghost_member-attribution:/home/ghost/ghost/member-attribution/node_modules:delegated
+ - node_modules_ghost_members-csv:/home/ghost/ghost/members-csv/node_modules:delegated
+ - node_modules_ghost_mw-error-handler:/home/ghost/ghost/mw-error-handler/node_modules:delegated
+ - node_modules_ghost_mw-vhost:/home/ghost/ghost/mw-vhost/node_modules:delegated
+ - node_modules_ghost_offers:/home/ghost/ghost/offers/node_modules:delegated
+ - node_modules_ghost_post-events:/home/ghost/ghost/post-events/node_modules:delegated
+ - node_modules_ghost_post-revisions:/home/ghost/ghost/post-revisions/node_modules:delegated
+ - node_modules_ghost_prometheus-metrics:/home/ghost/ghost/prometheus-metrics/node_modules:delegated
+ - node_modules_ghost_security:/home/ghost/ghost/security/node_modules:delegated
+ - node_modules_ghost_tiers:/home/ghost/ghost/tiers/node_modules:delegated
+ - node_modules_ghost_webmentions:/home/ghost/ghost/webmentions/node_modules:delegated
+ - node_modules_apps_admin-x-activitypub:/home/ghost/apps/admin-x-activitypub/node_modules:delegated
+ - node_modules_apps_admin-x-design-system:/home/ghost/apps/admin-x-design-system/node_modules:delegated
+ - node_modules_apps_admin-x-framework:/home/ghost/apps/admin-x-framework/node_modules:delegated
+ - node_modules_apps_admin-x-settings:/home/ghost/apps/admin-x-settings/node_modules:delegated
+ - node_modules_apps_announcement-bar:/home/ghost/apps/announcement-bar/node_modules:delegated
+ - node_modules_apps_comments-ui:/home/ghost/apps/comments-ui/node_modules:delegated
+ - node_modules_apps_portal:/home/ghost/apps/portal/node_modules:delegated
+ - node_modules_apps_posts:/home/ghost/apps/posts/node_modules:delegated
+ - node_modules_apps_shade:/home/ghost/apps/shade/node_modules:delegated
+ - node_modules_apps_signup-form:/home/ghost/apps/signup-form/node_modules:delegated
+ - node_modules_apps_sodo-search:/home/ghost/apps/sodo-search/node_modules:delegated
+ - node_modules_apps_stats:/home/ghost/apps/stats/node_modules:delegated
+ environment:
+ - DEBUG=${DEBUG:-}
+ - SSH_AUTH_SOCK=/ssh-agent
+ - NX_DAEMON=${NX_DAEMON:-true}
+ - GHOST_DEV_IS_DOCKER=true
+ - GHOST_DEV_APP_FLAGS=${GHOST_DEV_APP_FLAGS:-}
+ - GHOST_UPSTREAM=${GHOST_UPSTREAM:-}
+ - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
+ - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-}
+ - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-}
+
services:
ghost:
+ <<: *service-template
build:
context: .
dockerfile: ./.docker/Dockerfile
@@ -19,91 +75,32 @@ services:
- "7173:7173" # Comments
- "7174:7174" # Comments HTTPS
profiles: [ ghost ]
- volumes:
- # Mount the source code
- - .:/home/ghost
-
- ## SSH Agent forwarding
- - ${SSH_AUTH_SOCK}:/ssh-agent
-
- ## Git config
- - ${HOME}/.gitconfig:/root/.gitconfig:ro
-
- # Volume exclusions:
- ## Prevent collisions between host and container node_modules
- - node_modules_ghost_root:/home/ghost/node_modules:delegated
- - node_modules_ghost_admin:/home/ghost/ghost/admin/node_modules:delegated
- - node_modules_ghost_api-framework:/home/ghost/ghost/api-framework/node_modules:delegated
- - node_modules_ghost_constants:/home/ghost/ghost/constants/node_modules:delegated
- - node_modules_ghost_core:/home/ghost/ghost/core/node_modules:delegated
- - node_modules_ghost_custom-fonts:/home/ghost/ghost/custom-fonts/node_modules:delegated
- - node_modules_ghost_domain-events:/home/ghost/ghost/domain-events/node_modules:delegated
- - node_modules_ghost_donations:/home/ghost/ghost/donations/node_modules:delegated
- - node_modules_ghost_email-addresses:/home/ghost/ghost/email-addresses/node_modules:delegated
- - node_modules_ghost_email-service:/home/ghost/ghost/email-service/node_modules:delegated
- - node_modules_ghost_html-to-plaintext:/home/ghost/ghost/html-to-plaintext/node_modules:delegated
- - node_modules_ghost_i18n:/home/ghost/ghost/i18n/node_modules:delegated
- - node_modules_ghost_job-manager:/home/ghost/ghost/job-manager/node_modules:delegated
- - node_modules_ghost_link-replacer:/home/ghost/ghost/link-replacer/node_modules:delegated
- - node_modules_ghost_member-attribution:/home/ghost/ghost/member-attribution/node_modules:delegated
- - node_modules_ghost_members-csv:/home/ghost/ghost/members-csv/node_modules:delegated
- - node_modules_ghost_mw-error-handler:/home/ghost/ghost/mw-error-handler/node_modules:delegated
- - node_modules_ghost_mw-vhost:/home/ghost/ghost/mw-vhost/node_modules:delegated
- - node_modules_ghost_offers:/home/ghost/ghost/offers/node_modules:delegated
- - node_modules_ghost_post-events:/home/ghost/ghost/post-events/node_modules:delegated
- - node_modules_ghost_post-revisions:/home/ghost/ghost/post-revisions/node_modules:delegated
- - node_modules_ghost_prometheus-metrics:/home/ghost/ghost/prometheus-metrics/node_modules:delegated
- - node_modules_ghost_security:/home/ghost/ghost/security/node_modules:delegated
- - node_modules_ghost_tiers:/home/ghost/ghost/tiers/node_modules:delegated
- - node_modules_ghost_webmentions:/home/ghost/ghost/webmentions/node_modules:delegated
- - node_modules_apps_admin-x-activitypub:/home/ghost/apps/admin-x-activitypub/node_modules:delegated
- - node_modules_apps_admin-x-design-system:/home/ghost/apps/admin-x-design-system/node_modules:delegated
- - node_modules_apps_admin-x-framework:/home/ghost/apps/admin-x-framework/node_modules:delegated
- - node_modules_apps_admin-x-settings:/home/ghost/apps/admin-x-settings/node_modules:delegated
- - node_modules_apps_announcement-bar:/home/ghost/apps/announcement-bar/node_modules:delegated
- - node_modules_apps_comments-ui:/home/ghost/apps/comments-ui/node_modules:delegated
- - node_modules_apps_portal:/home/ghost/apps/portal/node_modules:delegated
- - node_modules_apps_posts:/home/ghost/apps/posts/node_modules:delegated
- - node_modules_apps_shade:/home/ghost/apps/shade/node_modules:delegated
- - node_modules_apps_signup-form:/home/ghost/apps/signup-form/node_modules:delegated
- - node_modules_apps_sodo-search:/home/ghost/apps/sodo-search/node_modules:delegated
- - node_modules_apps_stats:/home/ghost/apps/stats/node_modules:delegated
tty: true
depends_on:
mysql:
condition: service_healthy
redis:
condition: service_healthy
- environment:
- - DEBUG=${DEBUG:-}
- - SSH_AUTH_SOCK=/ssh-agent
- - NX_DAEMON=${NX_DAEMON:-true}
- - GHOST_DEV_IS_DOCKER=true
- - GHOST_DEV_APP_FLAGS=${GHOST_DEV_APP_FLAGS:-}
- - GHOST_UPSTREAM=${GHOST_UPSTREAM:-}
- - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-}
- - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-}
- - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-}
+
tinybird:
- extends:
- service: ghost
+ <<: *service-template
build:
context: .
dockerfile: ./.docker/Dockerfile
target: tinybird
working_dir: /home/ghost/ghost/web-analytics
- profiles: [ tinybird]
+ profiles: [ tinybird ]
tty: true
browser-tests:
- extends:
- service: ghost
+ <<: *service-template
build:
context: .
dockerfile: ./.docker/Dockerfile
target: browser-tests
command: [ "yarn", "test:browser" ]
profiles: [ browser-tests ]
+ tty: true
mysql:
image: mysql:8.4.5
diff --git a/ghost/admin/.lint-todo b/ghost/admin/.lint-todo
index 00530075484..ee4ddb6bece 100644
--- a/ghost/admin/.lint-todo
+++ b/ghost/admin/.lint-todo
@@ -558,3 +558,8 @@ add|ember-template-lint|no-redundant-role|136|24|136|24|ce988c0098c6e4845fc3307e
add|ember-template-lint|no-redundant-role|91|40|91|40|9d2eded16257516b455504637aab4057b3c0c9ef|1745798400000|1756166400000|1761350400000|app/templates/mentions.hbs
add|ember-template-lint|no-redundant-role|113|20|113|20|0418319e2dce98b674dbb6657d185f465d21f87d|1745798400000|1756166400000|1761350400000|app/templates/mentions.hbs
add|ember-template-lint|no-redundant-role|11|20|11|20|bc4fbabe3d468440d8a2c70e92cec991caca41e2|1745798400000|1756166400000|1761350400000|app/components/dashboard/onboarding/share-modal.hbs
+remove|ember-template-lint|no-action|73|82|73|82|f30d469e4ae668f05aca2f92a124a6b4748847a3|1730678400000|1741046400000|1746230400000|app/components/gh-post-settings-menu.hbs
+remove|ember-template-lint|no-action|80|92|80|92|f30d469e4ae668f05aca2f92a124a6b4748847a3|1730678400000|1741046400000|1746230400000|app/components/gh-post-settings-menu.hbs
+remove|ember-template-lint|no-action|5|14|5|14|a90edd9a99596008f60bfcdbc6befe7fe8d26321|1730678400000|1741046400000|1746230400000|app/components/gh-psm-tags-input.hbs
+remove|ember-template-lint|no-action|6|14|6|14|3924d7cfb394cbcfd6b1bf9cf13a5040ac8f94b5|1743984000000|1754352000000|1759536000000|app/components/gh-psm-tags-input.hbs
+remove|ember-template-lint|no-action|10|20|10|20|8b7921c0514cfd3fec8c3a474187048bc26faf7f|1743984000000|1754352000000|1759536000000|app/components/gh-psm-tags-input.hbs
diff --git a/ghost/admin/app/components/gh-post-settings-menu.hbs b/ghost/admin/app/components/gh-post-settings-menu.hbs
index 97683188eeb..d6698a4dcb7 100644
--- a/ghost/admin/app/components/gh-post-settings-menu.hbs
+++ b/ghost/admin/app/components/gh-post-settings-menu.hbs
@@ -70,14 +70,14 @@
{{#unless this.session.user.isContributor}}
-
+
{{/unless}}
{{#if this.showVisibilityInput}}
-
+
{{#if (eq this.post.visibility "tiers")}}
@@ -617,7 +617,7 @@
@focus-out={{action "setMetaTitle" this.metaTitleScratch}}
@stopEnterKeyDownPropagation={{true}}
data-test-field="meta-title" />
- Recommended: 60 characters. You’ve used {{gh-count-down-characters this.metaTitleScratch 60}}
+ Recommended: 60 characters. You've used {{gh-count-down-characters this.metaTitleScratch 60}}
@@ -633,7 +633,7 @@
@focus-out={{action "setMetaDescription" this.metaDescriptionScratch}}
@stopEnterKeyDownPropagation="true"
data-test-field="meta-description" />
- Recommended: 145 characters. You’ve used {{gh-count-down-characters this.metaDescriptionScratch 145}}
+ Recommended: 145 characters. You've used {{gh-count-down-characters this.metaDescriptionScratch 145}}
diff --git a/ghost/admin/app/components/gh-psm-tags-input.hbs b/ghost/admin/app/components/gh-psm-tags-input.hbs
index d2f59f5aa57..93bf01e0843 100644
--- a/ghost/admin/app/components/gh-psm-tags-input.hbs
+++ b/ghost/admin/app/components/gh-psm-tags-input.hbs
@@ -2,12 +2,12 @@
@extra={{hash
tokenComponent=(component "gh-token-input/tag-token")
}}
- @onChange={{action "updateTags"}}
- @onCreate={{action "createTag"}}
+ @onChange={{this.updateTags}}
+ @onCreate={{this.createTag}}
@options={{this.availableTags}}
@renderInPlace={{true}}
@selected={{this.post.tags}}
- @showCreateWhen={{action "hideCreateOptionOnMatchingTag"}}
+ @showCreateWhen={{this.hideCreateOptionOnMatchingTag}}
@triggerId={{this.triggerId}}
@triggerClass={{@triggerClass}}
/>
diff --git a/ghost/core/core/server/services/members/api.js b/ghost/core/core/server/services/members/api.js
index 2b5d36c00ca..8dc02f0e80a 100644
--- a/ghost/core/core/server/services/members/api.js
+++ b/ghost/core/core/server/services/members/api.js
@@ -238,7 +238,8 @@ function createApiInstance(config) {
emailSuppressionList,
settingsCache,
sentry,
- settingsHelpers
+ settingsHelpers,
+ urlUtils
});
return membersApiInstance;
diff --git a/ghost/core/core/server/services/members/members-api/controllers/RouterController.js b/ghost/core/core/server/services/members/members-api/controllers/RouterController.js
index b4f36e69e61..8154ccc0620 100644
--- a/ghost/core/core/server/services/members/members-api/controllers/RouterController.js
+++ b/ghost/core/core/server/services/members/members-api/controllers/RouterController.js
@@ -1,7 +1,7 @@
const tpl = require('@tryghost/tpl');
const logging = require('@tryghost/logging');
const sanitizeHtml = require('sanitize-html');
-const {BadRequestError, NoPermissionError, UnauthorizedError, DisabledFeatureError} = require('@tryghost/errors');
+const {BadRequestError, NoPermissionError, UnauthorizedError, DisabledFeatureError, NotFoundError} = require('@tryghost/errors');
const errors = require('@tryghost/errors');
const {isEmail} = require('@tryghost/validator');
@@ -46,6 +46,7 @@ module.exports = class RouterController {
* @param {any} deps.newslettersService
* @param {any} deps.sentry
* @param {any} deps.settingsCache
+ * @param {any} deps.urlUtils
*/
constructor({
offersAPI,
@@ -62,7 +63,8 @@ module.exports = class RouterController {
labsService,
newslettersService,
sentry,
- settingsCache
+ settingsCache,
+ urlUtils
}) {
this._offersAPI = offersAPI;
this._paymentsService = paymentsService;
@@ -79,6 +81,7 @@ module.exports = class RouterController {
this._newslettersService = newslettersService;
this._sentry = sentry || undefined;
this._settingsCache = settingsCache;
+ this._urlUtils = urlUtils;
}
async ensureStripe(_req, res, next) {
@@ -319,13 +322,35 @@ module.exports = class RouterController {
* @returns
*/
async _createSubscriptionCheckoutSession(options) {
+ if (options.tier && options.tier.id === 'free') {
+ throw new BadRequestError({
+ message: tpl(messages.badRequest)
+ });
+ }
+
+ const tier = options.tier;
+
+ if (!tier) {
+ throw new NotFoundError({
+ message: tpl(messages.tierNotFound)
+ });
+ }
+
+ if (tier.status === 'archived') {
+ throw new NoPermissionError({
+ message: tpl(messages.tierArchived)
+ });
+ }
+
if (options.offer) {
// Attach offer information to stripe metadata for free trial offers
// free trial offers don't have associated stripe coupons
options.metadata.offer = options.offer.id;
}
- if (!options.member && options.email) {
+ const member = options.member;
+
+ if (!member && options.email) {
// Create a signup link if there is no member with this email address
options.successUrl = await this._magicLinkService.getMagicLink({
tokenData: {
@@ -342,22 +367,26 @@ module.exports = class RouterController {
});
}
- const restrictCheckout = options.member?.get('status') === 'paid';
-
- if (restrictCheckout) {
- // This member is already subscribed to a paid tier
- // We don't want to create a duplicate subscription
- if (!options.isAuthenticated && options.email) {
- try {
- await this._sendEmailWithMagicLink({email: options.email, requestedType: 'signin'});
- } catch (err) {
- logging.warn(err);
+ if (member) {
+ options.successUrl = this._generateSuccessUrl(options.successUrl, tier.welcomePageURL);
+
+ const restrictCheckout = member.get('status') === 'paid';
+
+ if (restrictCheckout) {
+ // This member is already subscribed to a paid tier
+ // We don't want to create a duplicate subscription
+ if (!options.isAuthenticated && options.email) {
+ try {
+ await this._sendEmailWithMagicLink({email: options.email, requestedType: 'signin'});
+ } catch (err) {
+ logging.warn(err);
+ }
}
+ throw new NoPermissionError({
+ message: messages.existingSubscription,
+ code: 'CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION'
+ });
}
- throw new NoPermissionError({
- message: messages.existingSubscription,
- code: 'CANNOT_CHECKOUT_WITH_EXISTING_SUBSCRIPTION'
- });
}
try {
@@ -374,6 +403,34 @@ module.exports = class RouterController {
}
}
+ // Helper method to generate success URL with tier welcome page if available
+ _generateSuccessUrl(originalSuccessUrl, welcomePageURL) {
+ // If there's no welcome page URL, use the original success URL
+ if (!welcomePageURL) {
+ return originalSuccessUrl;
+ }
+
+ try {
+ // Create URL objects
+ const siteUrl = this._urlUtils.getSiteUrl();
+
+ // This will throw if welcomePageURL is invalid
+ const welcomeUrl = new URL(
+ welcomePageURL.startsWith('http') ? welcomePageURL : welcomePageURL,
+ siteUrl
+ );
+
+ // Add success parameters
+ welcomeUrl.searchParams.set('success', 'true');
+ welcomeUrl.searchParams.set('action', 'signup');
+
+ return welcomeUrl.href;
+ } catch (err) {
+ logging.warn(`Invalid welcome page URL "${welcomePageURL}", using original success URL`, err);
+ return originalSuccessUrl;
+ }
+ }
+
/**
*
* @param {object} options
@@ -478,6 +535,11 @@ module.exports = class RouterController {
...options,
...data
});
+
+ // Add welcome_page_url to the response if available and member is authenticated
+ if (isAuthenticated && data.tier && data.tier.welcomePageURL) {
+ response.welcomePageUrl = data.tier.welcomePageURL;
+ }
} else if (type === 'donation') {
options.personalNote = parsePersonalNote(req.body.personalNote);
response = await this._createDonationCheckoutSession(options);
diff --git a/ghost/core/core/server/services/members/members-api/members-api.js b/ghost/core/core/server/services/members/members-api/members-api.js
index 9368eda7172..662d980a2c5 100644
--- a/ghost/core/core/server/services/members/members-api/members-api.js
+++ b/ghost/core/core/server/services/members/members-api/members-api.js
@@ -72,7 +72,8 @@ module.exports = function MembersAPI({
emailSuppressionList,
settingsCache,
sentry,
- settingsHelpers
+ settingsHelpers,
+ urlUtils
}) {
const tokenService = new TokenService({
privateKey,
@@ -196,7 +197,8 @@ module.exports = function MembersAPI({
labsService,
newslettersService,
settingsCache,
- sentry
+ sentry,
+ urlUtils
});
const wellKnownController = new WellKnownController({
diff --git a/ghost/core/core/server/services/stats/TopContentStatsService.js b/ghost/core/core/server/services/stats/ContentStatsService.js
similarity index 96%
rename from ghost/core/core/server/services/stats/TopContentStatsService.js
rename to ghost/core/core/server/services/stats/ContentStatsService.js
index e9d5e4593af..7b6ef39ac8d 100644
--- a/ghost/core/core/server/services/stats/TopContentStatsService.js
+++ b/ghost/core/core/server/services/stats/ContentStatsService.js
@@ -9,7 +9,7 @@ const logging = require('@tryghost/logging');
* @property {string} [title] - Page title
*/
-class TopContentStatsService {
+class ContentStatsService {
/**
* @param {object} deps
* @param {import('knex').Knex} deps.knex - Database client
@@ -38,17 +38,17 @@ class TopContentStatsService {
if (!this.tinybirdClient) {
return {data: []};
}
-
+
// Step 1: Get raw data from Tinybird
const rawData = await this.fetchRawTopContentData(options);
-
+
if (!rawData || !rawData.length) {
return {data: []};
}
-
+
// Step 2: Enrich the data with titles
const enrichedData = await this.enrichTopContentData(rawData);
-
+
return {data: enrichedData};
} catch (error) {
logging.error('Error fetching top content:');
@@ -56,7 +56,7 @@ class TopContentStatsService {
return {data: []};
}
}
-
+
/**
* Fetch raw top pages data from Tinybird
* @param {Object} options - Query options with snake_case keys
@@ -71,10 +71,10 @@ class TopContentStatsService {
memberStatus: options.member_status,
tbVersion: options.tb_version
};
-
+
return await this.tinybirdClient.fetch('api_top_pages', tinybirdOptions);
}
-
+
/**
* Extract post UUIDs from page data (internal method)
* @param {Array} data - Raw page data
@@ -88,7 +88,7 @@ class TopContentStatsService {
})
.filter(Boolean);
}
-
+
/**
* Lookup post titles in the database
* @param {Array} uuids - Post UUIDs to look up
@@ -98,11 +98,11 @@ class TopContentStatsService {
if (!uuids.length) {
return {};
}
-
+
const posts = await this.knex.select('uuid', 'title', 'id')
.from('posts')
.whereIn('uuid', uuids);
-
+
return posts.reduce((map, post) => {
map[post.uuid] = {
title: post.title,
@@ -111,7 +111,7 @@ class TopContentStatsService {
return map;
}, {});
}
-
+
/**
* Get resource title using UrlService
* @param {string} pathname - Path to look up
@@ -121,10 +121,10 @@ class TopContentStatsService {
if (!this.urlService) {
return null;
}
-
+
try {
const resource = this.urlService.getResource(pathname);
-
+
if (resource && resource.data) {
if (resource.data.title) {
return {
@@ -144,10 +144,10 @@ class TopContentStatsService {
logging.warn(`Error looking up resource for ${pathname}: ${err.message}`);
}
}
-
+
return null;
}
-
+
/**
* Enrich top pages data with titles
* @param {Array} data - Raw page data
@@ -157,11 +157,11 @@ class TopContentStatsService {
if (!data || !data.length) {
return [];
}
-
+
// Extract post UUIDs and lookup titles
const postUuids = this.extractPostUuids(data);
const titleMap = await this.lookupPostTitles(postUuids);
-
+
// Enrich the data with post titles or UrlService lookups
return Promise.all(data.map(async (item) => {
// Check if post_uuid is available directly
@@ -172,7 +172,7 @@ class TopContentStatsService {
post_id: titleMap[item.post_uuid].id
};
}
-
+
// Use UrlService for pages without post_uuid
const resourceInfo = this.getResourceTitle(item.pathname);
if (resourceInfo) {
@@ -182,7 +182,7 @@ class TopContentStatsService {
resourceType: resourceInfo.resourceType
};
}
-
+
// Otherwise fallback to pathname (removing leading/trailing slashes)
const formattedPath = item.pathname.replace(/^\/|\/$/g, '') || 'Home';
return {
@@ -193,4 +193,4 @@ class TopContentStatsService {
}
}
-module.exports = TopContentStatsService;
\ No newline at end of file
+module.exports = ContentStatsService;
diff --git a/ghost/core/core/server/services/stats/StatsService.js b/ghost/core/core/server/services/stats/StatsService.js
index ed72ce42a62..61f6dc8a696 100644
--- a/ghost/core/core/server/services/stats/StatsService.js
+++ b/ghost/core/core/server/services/stats/StatsService.js
@@ -2,8 +2,8 @@ const MRRService = require('./MrrStatsService');
const MembersService = require('./MembersStatsService');
const SubscriptionStatsService = require('./SubscriptionStatsService');
const ReferrersStatsService = require('./ReferrersStatsService');
-const TopContentStatsService = require('./TopContentStatsService');
const PostsStatsService = require('./PostsStatsService');
+const ContentStatsService = require('./ContentStatsService');
const tinybird = require('./utils/tinybird');
class StatsService {
@@ -13,16 +13,16 @@ class StatsService {
* @param {MembersService} deps.members
* @param {SubscriptionStatsService} deps.subscriptions
* @param {ReferrersStatsService} deps.referrers
- * @param {TopContentStatsService} deps.topContent
* @param {PostsStatsService} deps.posts
+ * @param {ContentStatsService} deps.content
**/
constructor(deps) {
this.mrr = deps.mrr;
this.members = deps.members;
this.subscriptions = deps.subscriptions;
this.referrers = deps.referrers;
- this.topContent = deps.topContent;
- this.topPosts = deps.posts;
+ this.posts = deps.posts;
+ this.content = deps.content;
}
async getMRRHistory() {
@@ -41,7 +41,7 @@ class StatsService {
startDate: options.dateFrom
};
delete mappedOptions.dateFrom;
-
+
return this.members.getCountHistory(mappedOptions);
}
@@ -67,7 +67,7 @@ class StatsService {
* @param {Object} options
*/
async getTopContent(options = {}) {
- return await this.topContent.getTopContent(options);
+ return await this.content.getTopContent(options);
}
/**
@@ -77,7 +77,7 @@ class StatsService {
*/
async getTopPosts(options = {}) {
// Return the original { data: results } structure
- const result = await this.topPosts.getTopPosts(options);
+ const result = await this.posts.getTopPosts(options);
return result;
}
@@ -111,8 +111,8 @@ class StatsService {
members: new MembersService(deps),
subscriptions: new SubscriptionStatsService(deps),
referrers: new ReferrersStatsService(deps),
- topContent: new TopContentStatsService(depsWithTinybird),
- posts: new PostsStatsService(deps)
+ posts: new PostsStatsService(deps),
+ content: new ContentStatsService(depsWithTinybird)
});
}
}
diff --git a/ghost/core/package.json b/ghost/core/package.json
index 193b596ec21..161aceb59ac 100644
--- a/ghost/core/package.json
+++ b/ghost/core/package.json
@@ -232,7 +232,7 @@
"@types/bookshelf": "1.2.9",
"@types/common-tags": "1.8.4",
"@types/jsonwebtoken": "9.0.9",
- "@types/node": "22.15.3",
+ "@types/node": "22.15.7",
"@types/node-jose": "1.1.13",
"@types/sinon": "17.0.4",
"@types/supertest": "6.0.3",
diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/router.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/router.test.js
index eef7eb75dec..67d2b0b0def 100644
--- a/ghost/core/test/unit/server/services/members/members-api/controllers/router.test.js
+++ b/ghost/core/test/unit/server/services/members/members-api/controllers/router.test.js
@@ -2,6 +2,7 @@ const sinon = require('sinon');
const assert = require('assert').strict;
const errors = require('@tryghost/errors');
+// @ts-ignore - Intentionally ignoring TypeScript errors for tests
const RouterController = require('../../../../../../../core/server/services/members/members-api/controllers/RouterController');
describe('RouterController', function () {
@@ -486,6 +487,122 @@ describe('RouterController', function () {
});
});
+ it('adds welcomePageUrl to response for authenticated members when tier has welcomePageURL', async function () {
+ const routerController = new RouterController({
+ tiersService: {
+ api: {
+ read: sinon.stub().resolves({
+ id: 'tier_123',
+ welcomePageURL: '/welcome-page/'
+ })
+ }
+ },
+ paymentsService: {
+ getPaymentLink: sinon.stub().resolves('https://example.com/checkout/')
+ },
+ tokenService: {
+ decodeToken: sinon.stub().resolves({sub: 'test@example.com'})
+ },
+ memberRepository: {
+ get: sinon.stub().resolves({
+ email: 'test@example.com',
+ get: sinon.stub().returns('free'),
+ related: sinon.stub().returns({
+ query: sinon.stub().returns({
+ fetch: sinon.stub().resolves([])
+ })
+ })
+ })
+ },
+ urlUtils: {
+ getSiteUrl: sinon.stub().returns('https://example.com/')
+ },
+ memberAttributionService: {
+ getAttribution: sinon.stub().resolves({})
+ }
+ });
+
+ const req = {
+ body: {
+ tierId: 'tier_123',
+ cadence: 'month',
+ successUrl: 'https://example.com/success',
+ cancelUrl: 'https://example.com/cancel',
+ identity: 'identity-token',
+ metadata: {}
+ }
+ };
+
+ const res = {
+ writeHead: sinon.stub(),
+ end: sinon.stub()
+ };
+
+ await routerController.createCheckoutSession(req, res);
+
+ res.end.calledOnce.should.be.true();
+ const responseBody = JSON.parse(res.end.firstCall.args[0]);
+ assert.equal(responseBody.welcomePageUrl, '/welcome-page/');
+ });
+
+ it('does not add welcomePageUrl to response when tier has no welcomePageURL', async function () {
+ const routerController = new RouterController({
+ tiersService: {
+ api: {
+ read: sinon.stub().resolves({
+ id: 'tier_123'
+ // No welcomePageURL
+ })
+ }
+ },
+ paymentsService: {
+ getPaymentLink: sinon.stub().resolves('https://example.com/checkout/')
+ },
+ tokenService: {
+ decodeToken: sinon.stub().resolves({sub: 'test@example.com'})
+ },
+ memberRepository: {
+ get: sinon.stub().resolves({
+ email: 'test@example.com',
+ get: sinon.stub().returns('free'),
+ related: sinon.stub().returns({
+ query: sinon.stub().returns({
+ fetch: sinon.stub().resolves([])
+ })
+ })
+ })
+ },
+ urlUtils: {
+ getSiteUrl: sinon.stub().returns('https://example.com/')
+ },
+ memberAttributionService: {
+ getAttribution: sinon.stub().resolves({})
+ }
+ });
+
+ const req = {
+ body: {
+ tierId: 'tier_123',
+ cadence: 'month',
+ successUrl: 'https://example.com/success',
+ cancelUrl: 'https://example.com/cancel',
+ identity: 'identity-token',
+ metadata: {}
+ }
+ };
+
+ const res = {
+ writeHead: sinon.stub(),
+ end: sinon.stub()
+ };
+
+ await routerController.createCheckoutSession(req, res);
+
+ res.end.calledOnce.should.be.true();
+ const responseBody = JSON.parse(res.end.firstCall.args[0]);
+ assert.equal(responseBody.welcomePageUrl, undefined);
+ });
+
afterEach(function () {
sinon.restore();
});
@@ -687,4 +804,62 @@ describe('RouterController', function () {
});
});
});
+
+ describe('_generateSuccessUrl', function () {
+ let urlUtilsStub;
+
+ beforeEach(function () {
+ urlUtilsStub = {
+ getSiteUrl: sinon.stub().returns('https://example.com/')
+ };
+ });
+
+ it('returns original success URL when welcomePageURL is not set', function () {
+ const routerController = new RouterController({
+ urlUtils: urlUtilsStub
+ });
+
+ const originalUrl = 'https://example.com/success';
+ const result = routerController._generateSuccessUrl(originalUrl, null);
+
+ assert.equal(result, originalUrl);
+ });
+
+ it('returns welcome page URL with success parameters when welcomePageURL is set', function () {
+ const routerController = new RouterController({
+ urlUtils: urlUtilsStub
+ });
+
+ const originalUrl = 'https://example.com/success';
+ const welcomePageURL = '/welcome-paid-members/';
+ const result = routerController._generateSuccessUrl(originalUrl, welcomePageURL);
+
+ assert.equal(result, 'https://example.com/welcome-paid-members/?success=true&action=signup');
+ });
+
+ it('handles absolute URLs in welcomePageURL', function () {
+ const routerController = new RouterController({
+ urlUtils: urlUtilsStub
+ });
+
+ const originalUrl = 'https://example.com/success';
+ const welcomePageURL = 'https://external-site.com/welcome';
+ const result = routerController._generateSuccessUrl(originalUrl, welcomePageURL);
+
+ assert.equal(result, 'https://external-site.com/welcome?success=true&action=signup');
+ });
+
+ it('returns original URL if welcomePageURL is invalid', function () {
+ const routerController = new RouterController({
+ urlUtils: urlUtilsStub
+ });
+
+ const originalUrl = 'https://example.com/success';
+ // Using a URL that would throw an error when trying to create a URL object
+ const welcomePageURL = 'http://invalid-url:-with-bad-port';
+ const result = routerController._generateSuccessUrl(originalUrl, welcomePageURL);
+
+ assert.equal(result, originalUrl);
+ });
+ });
});
diff --git a/ghost/core/test/unit/server/services/stats/TopContentStatsService.test.js b/ghost/core/test/unit/server/services/stats/content.test.js.js
similarity index 94%
rename from ghost/core/test/unit/server/services/stats/TopContentStatsService.test.js
rename to ghost/core/test/unit/server/services/stats/content.test.js.js
index f82158dd958..3b122a241c0 100644
--- a/ghost/core/test/unit/server/services/stats/TopContentStatsService.test.js
+++ b/ghost/core/test/unit/server/services/stats/content.test.js.js
@@ -1,9 +1,9 @@
const sinon = require('sinon');
const should = require('should');
-const TopContentStatsService = require('../../../../../core/server/services/stats/TopContentStatsService');
+const ContentStatsService = require('../../../../../core/server/services/stats/ContentStatsService');
const tinybird = require('../../../../../core/server/services/stats/utils/tinybird');
-describe('TopContentStatsService', function () {
+describe('ContentStatsService', function () {
let service;
let mockKnex;
let mockUrlService;
@@ -23,7 +23,7 @@ describe('TopContentStatsService', function () {
mockUrlService = {
getResource: sinon.stub()
};
-
+
// Create mock Tinybird client
mockTinybirdClient = {
buildRequest: sinon.stub(),
@@ -35,7 +35,7 @@ describe('TopContentStatsService', function () {
sinon.stub(tinybird, 'create').returns(mockTinybirdClient);
// Create service instance with mocked dependencies
- service = new TopContentStatsService({
+ service = new ContentStatsService({
knex: mockKnex,
urlService: mockUrlService,
tinybirdClient: mockTinybirdClient
@@ -78,7 +78,7 @@ describe('TopContentStatsService', function () {
should.exist(result.options);
should.exist(result.options.headers);
result.options.headers.Authorization.should.equal('Bearer tb-token');
-
+
mockTinybirdClient.buildRequest.calledWith('api_top_pages', options).should.be.true();
});
@@ -100,7 +100,7 @@ describe('TopContentStatsService', function () {
const result = mockTinybirdClient.parseResponse(mockResponse);
should.exist(result);
result.should.be.an.Array().with.lengthOf(2);
-
+
mockTinybirdClient.parseResponse.calledWith(mockResponse).should.be.true();
});
});
@@ -141,14 +141,14 @@ describe('TopContentStatsService', function () {
it('queries database and builds title map', async function () {
const result = await service.lookupPostTitles(['post-1', 'post-2']);
-
+
should.exist(result);
result.should.have.properties(['post-1', 'post-2']);
result['post-1'].should.have.property('title', 'Test Post 1');
result['post-1'].should.have.property('id', 'post-id-1');
result['post-2'].should.have.property('title', 'Test Post 2');
result['post-2'].should.have.property('id', 'post-id-2');
-
+
// Verify knex was called correctly
mockKnex.select.calledWith('uuid', 'title', 'id').should.be.true();
mockKnex.from.calledWith('posts').should.be.true();
@@ -159,11 +159,11 @@ describe('TopContentStatsService', function () {
describe('getResourceTitle', function () {
it('returns null if urlService is not available', function () {
// Create service without urlService
- const serviceNoUrl = new TopContentStatsService({
+ const serviceNoUrl = new ContentStatsService({
knex: mockKnex,
urlService: null
});
-
+
const result = serviceNoUrl.getResourceTitle('/about/');
should.not.exist(result);
});
@@ -175,7 +175,7 @@ describe('TopContentStatsService', function () {
type: 'page'
}
});
-
+
const result = service.getResourceTitle('/about/');
should.exist(result);
result.should.have.properties(['title', 'resourceType']);
@@ -190,7 +190,7 @@ describe('TopContentStatsService', function () {
type: 'tag'
}
});
-
+
const result = service.getResourceTitle('/tag/news/');
should.exist(result);
result.should.have.properties(['title', 'resourceType']);
@@ -200,14 +200,14 @@ describe('TopContentStatsService', function () {
it('returns null if resource lookup fails', function () {
mockUrlService.getResource.withArgs('/not-found/').throws(new Error('Resource not found'));
-
+
const result = service.getResourceTitle('/not-found/');
should.not.exist(result);
});
it('returns null if resource has no data or title/name', function () {
mockUrlService.getResource.withArgs('/empty/').returns({});
-
+
const result = service.getResourceTitle('/empty/');
should.not.exist(result);
});
@@ -225,7 +225,7 @@ describe('TopContentStatsService', function () {
const result = await service.enrichTopContentData([]);
should.exist(result);
result.should.be.an.Array().with.lengthOf(0);
-
+
service.extractPostUuids.called.should.be.false();
service.lookupPostTitles.called.should.be.false();
});
@@ -235,16 +235,16 @@ describe('TopContentStatsService', function () {
{pathname: '/post-1/', post_uuid: 'post-1', visits: 100},
{pathname: '/post-2/', post_uuid: 'post-2', visits: 50}
];
-
+
const result = await service.enrichTopContentData(data);
-
+
should.exist(result);
result.should.be.an.Array().with.lengthOf(2);
result[0].title.should.equal('Test Post 1');
result[0].post_id.should.equal('post-id-1');
result[1].title.should.equal('Test Post 2');
result[1].post_id.should.equal('post-id-2');
-
+
service.extractPostUuids.calledOnce.should.be.true();
service.lookupPostTitles.calledOnce.should.be.true();
});
@@ -253,21 +253,21 @@ describe('TopContentStatsService', function () {
const data = [
{pathname: '/about/', visits: 100}
];
-
+
mockUrlService.getResource.withArgs('/about/').returns({
data: {
title: 'About Us',
type: 'page'
}
});
-
+
const result = await service.enrichTopContentData(data);
-
+
should.exist(result);
result.should.be.an.Array().with.lengthOf(1);
result[0].title.should.equal('About Us');
result[0].resourceType.should.equal('page');
-
+
service.getResourceTitle.calledWith('/about/').should.be.true();
});
@@ -275,11 +275,11 @@ describe('TopContentStatsService', function () {
const data = [
{pathname: '/unknown-page/', visits: 100}
];
-
+
mockUrlService.getResource.withArgs('/unknown-page/').returns(null);
-
+
const result = await service.enrichTopContentData(data);
-
+
should.exist(result);
result.should.be.an.Array().with.lengthOf(1);
result[0].title.should.equal('unknown-page');
@@ -289,11 +289,11 @@ describe('TopContentStatsService', function () {
const data = [
{pathname: '/', visits: 100}
];
-
+
mockUrlService.getResource.withArgs('/').returns(null);
-
+
const result = await service.enrichTopContentData(data);
-
+
should.exist(result);
result.should.be.an.Array().with.lengthOf(1);
result[0].title.should.equal('Home');
@@ -305,28 +305,28 @@ describe('TopContentStatsService', function () {
const expectedData = [
{pathname: '/test/', visits: 100}
];
-
+
mockTinybirdClient.fetch.resolves(expectedData);
-
+
// Use snake_case parameters as expected in the API
const options = {
date_from: '2023-01-01',
date_to: '2023-01-31'
};
-
+
const result = await service.fetchRawTopContentData(options);
-
+
should.exist(result);
result.should.be.an.Array().with.lengthOf(1);
result[0].pathname.should.equal('/test/');
result[0].visits.should.equal(100);
-
+
mockTinybirdClient.fetch.calledOnce.should.be.true();
-
+
// Verify that camelCase conversion happened - the first param should be the pipe name
const calledWith = mockTinybirdClient.fetch.firstCall.args;
calledWith[0].should.equal('api_top_pages');
-
+
// The second param should have camelCase properties
calledWith[1].should.have.property('dateFrom', '2023-01-01');
calledWith[1].should.have.property('dateTo', '2023-01-31');
@@ -336,16 +336,16 @@ describe('TopContentStatsService', function () {
it('returns null on API request failure', async function () {
mockTinybirdClient.fetch.resolves(null);
-
+
const options = {
date_from: '2023-01-01',
date_to: '2023-01-31'
};
-
+
const result = await service.fetchRawTopContentData(options);
-
+
should.not.exist(result);
-
+
// Verify that camelCase conversion happened
const calledWith = mockTinybirdClient.fetch.firstCall.args;
calledWith[0].should.equal('api_top_pages');
@@ -367,12 +367,12 @@ describe('TopContentStatsService', function () {
{pathname: '/test-2/', post_uuid: 'post-2', visits: 50}
];
service.fetchRawTopContentData.resolves(mockRawData);
-
+
const result = await service.getTopContent({
date_from: '2023-01-01',
date_to: '2023-01-31'
});
-
+
should.exist(result);
should.exist(result.data);
result.data.should.be.an.Array().with.lengthOf(2);
@@ -380,9 +380,9 @@ describe('TopContentStatsService', function () {
result.data[0].should.have.property('post_id');
result.data[1].should.have.property('title');
result.data[1].should.have.property('post_id');
-
+
service.fetchRawTopContentData.calledOnce.should.be.true();
-
+
// Verify the parameters were passed properly
const options = service.fetchRawTopContentData.firstCall.args[0];
options.should.have.property('date_from', '2023-01-01');
@@ -391,27 +391,27 @@ describe('TopContentStatsService', function () {
it('returns empty data array when fetch returns no data', async function () {
service.fetchRawTopContentData.resolves(null);
-
+
const result = await service.getTopContent({
date_from: '2023-01-01',
date_to: '2023-01-31'
});
-
+
should.exist(result);
should.exist(result.data);
result.data.should.be.an.Array().with.lengthOf(0);
-
+
service.fetchRawTopContentData.calledOnce.should.be.true();
});
it('returns empty data array on error', async function () {
service.fetchRawTopContentData.rejects(new Error('Test error'));
-
+
const result = await service.getTopContent({
date_from: '2023-01-01',
date_to: '2023-01-31'
});
-
+
should.exist(result);
should.exist(result.data);
result.data.should.be.an.Array().with.lengthOf(0);
@@ -419,16 +419,16 @@ describe('TopContentStatsService', function () {
it('returns empty data array when tinybirdClient is not available', async function () {
// Create a service without tinybirdClient
- const serviceNoTinybird = new TopContentStatsService({
+ const serviceNoTinybird = new ContentStatsService({
knex: mockKnex,
urlService: mockUrlService
});
-
+
const result = await serviceNoTinybird.getTopContent({
date_from: '2023-01-01',
date_to: '2023-01-31'
});
-
+
should.exist(result);
should.exist(result.data);
result.data.should.be.an.Array().with.lengthOf(0);
@@ -455,11 +455,11 @@ describe('TopContentStatsService', function () {
// Verify result is correct
should.exist(result);
result.should.be.an.Array().with.lengthOf(2);
-
+
// Verify tinybird client was called with correct parameters
mockTinybirdClient.fetch.calledOnce.should.be.true();
mockTinybirdClient.fetch.firstCall.args[0].should.equal('api_top_pages');
-
+
const tinybirdOptions = mockTinybirdClient.fetch.firstCall.args[1];
tinybirdOptions.should.have.property('dateFrom', '2023-01-01');
tinybirdOptions.should.have.property('dateTo', '2023-01-31');
@@ -469,11 +469,11 @@ describe('TopContentStatsService', function () {
it('handles null response from tinybird client', async function () {
mockTinybirdClient.fetch.resolves(null);
-
+
const result = await service.getTopContent({});
-
+
should.exist(result);
result.should.have.property('data').which.is.an.Array().with.lengthOf(0);
});
});
-});
\ No newline at end of file
+});
diff --git a/yarn.lock b/yarn.lock
index cc8e5cf0be1..f44ff2948d8 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8186,10 +8186,10 @@
dependencies:
"@types/node" "*"
-"@types/node@*", "@types/node@22.15.3", "@types/node@>=10.0.0", "@types/node@>=18.0.0", "@types/node@>=8.1.0":
- version "22.15.3"
- resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.3.tgz#b7fb9396a8ec5b5dfb1345d8ac2502060e9af68b"
- integrity sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==
+"@types/node@*", "@types/node@22.15.7", "@types/node@>=10.0.0", "@types/node@>=18.0.0", "@types/node@>=8.1.0":
+ version "22.15.7"
+ resolved "https://registry.yarnpkg.com/@types/node/-/node-22.15.7.tgz#f5b5ec1c2ff271f453a36b3f155cfbc25256da8a"
+ integrity sha512-3hieEH05p8cnASknk8cYV71K2Vqmn4Nv8gjvRc5N3XbMlBS4wPwsmsw5bcHw6ISL36vVFuAhElcQCf7Ir4bR0w==
dependencies:
undici-types "~6.21.0"