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"