diff --git a/.env.example b/.env.example index 0433d10e46c..8001b9354cf 100644 --- a/.env.example +++ b/.env.example @@ -4,7 +4,7 @@ # Docker Compose profiles to enable ## Run `docker compose config --profiles` to see all available profiles ## See https://docs.docker.com/compose/how-tos/profiles/ for more information -COMPOSE_PROFILES=ghost +# COMPOSE_PROFILES=stripe # Debug level to pass to Ghost # DEBUG= diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js index 5bcc937896d..983b08087cf 100644 --- a/.github/scripts/dev.js +++ b/.github/scripts/dev.js @@ -280,6 +280,12 @@ async function handleStripe() { } debug('at least one command provided'); + debug('resetting nx'); + await exec("yarn nx reset --onlyDaemon"); + debug('nx reset'); + await exec("yarn nx daemon --start"); + debug('nx daemon started'); + console.log(`Running projects: ${commands.map(c => chalk.green(c.name)).join(', ')}`); debug('creating concurrently promise'); diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 2607252792a..089e019d78e 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -17,6 +17,7 @@ export default defineConfig({ "process.env.DEBUG": false, // Shim env var utilized by the @tryghost/nql package }, server: { + port: 5174, host: true, allowedHosts: true }, diff --git a/compose.dev.yaml b/compose.dev.yaml index 5fda24440b7..830ca96f5b8 100644 --- a/compose.dev.yaml +++ b/compose.dev.yaml @@ -66,6 +66,7 @@ services: - ghost-dev-media:/home/ghost/ghost/core/content/media - ghost-dev-files:/home/ghost/ghost/core/content/files - ghost-dev-logs:/home/ghost/ghost/core/content/logs + - shared-config:/mnt/shared-config:ro environment: NODE_ENV: development NODE_TLS_REJECT_UNAUTHORIZED: "0" @@ -98,6 +99,9 @@ services: condition: service_healthy mailpit: condition: service_healthy + stripe: + condition: service_healthy + required: false healthcheck: test: ["CMD", "node", "-e", "fetch('http://localhost:2368',{redirect:'manual'}).then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))"] timeout: 5s @@ -123,10 +127,26 @@ services: ghost-dev: condition: service_healthy + stripe: + image: stripe/stripe-cli:latest + container_name: ghost-dev-stripe + entrypoint: ["/entrypoint.sh"] + profiles: ["stripe"] + volumes: + - ./docker/stripe/entrypoint.sh:/entrypoint.sh:ro + - shared-config:/mnt/shared-config + environment: + - GHOST_URL=http://ghost-dev:2368 + - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} + healthcheck: + test: ["CMD", "test", "-f", "/mnt/shared-config/.env.stripe"] + interval: 1s + retries: 120 volumes: mysql-data: redis-data: + shared-config: ghost-dev-data: ghost-dev-images: ghost-dev-media: diff --git a/compose.yml b/compose.yml index 6183dd002ee..0c0a9f235dd 100644 --- a/compose.yml +++ b/compose.yml @@ -294,6 +294,7 @@ services: - ./docker/stripe/entrypoint.sh:/entrypoint.sh:ro - shared-config:/mnt/shared-config environment: + - GHOST_URL=http://server:2368 - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-} diff --git a/docker/dev-gateway/Dockerfile b/docker/dev-gateway/Dockerfile index 20eb317f465..b1b531cbc72 100644 --- a/docker/dev-gateway/Dockerfile +++ b/docker/dev-gateway/Dockerfile @@ -4,7 +4,7 @@ RUN caddy add-package github.com/caddyserver/transform-encoder # Default proxy targets (can be overridden via environment variables) ENV GHOST_BACKEND=ghost-dev:2368 \ - ADMIN_DEV_SERVER=host.docker.internal:5173 \ + ADMIN_DEV_SERVER=host.docker.internal:5174 \ ADMIN_LIVE_RELOAD_SERVER=host.docker.internal:4200 \ PORTAL_DEV_SERVER=host.docker.internal:4175 \ COMMENTS_DEV_SERVER=host.docker.internal:7173 \ diff --git a/docker/dev-gateway/README.md b/docker/dev-gateway/README.md index a11ee55e78b..4fcfc257db5 100644 --- a/docker/dev-gateway/README.md +++ b/docker/dev-gateway/README.md @@ -12,7 +12,7 @@ The Caddy reverse proxy container: Caddy uses environment variables (set in `compose.dev.yaml`) to configure proxy targets: - `GHOST_BACKEND` - Ghost container hostname (e.g., `ghost-dev:2368`) -- `ADMIN_DEV_SERVER` - React admin dev server (e.g., `host.docker.internal:5173`) +- `ADMIN_DEV_SERVER` - React admin dev server (e.g., `host.docker.internal:5174`) - `ADMIN_LIVE_RELOAD_SERVER` - Ember live reload WebSocket (e.g., `host.docker.internal:4200`) - `PORTAL_DEV_SERVER` - Portal dev server (e.g., `host.docker.internal:4175`) - `COMMENTS_DEV_SERVER` - Comments UI (e.g., `host.docker.internal:7173`) @@ -48,8 +48,8 @@ The Caddyfile defines these routing rules: | `/ghost/assets/signup-form/*` | Signup dev server (port 6174) | Signup form widget | | `/ghost/assets/sodo-search/*` | Search dev server (port 4178) | Search widget (JS + CSS) | | `/ghost/assets/announcement-bar/*` | Announcement dev server (port 4177) | Announcement widget | -| `/ghost/assets/*` | Admin dev server (port 5173) | Other admin assets (fallback) | -| `/ghost/*` | Admin dev server (port 5173) | Admin interface | +| `/ghost/assets/*` | Admin dev server (port 5174) | Other admin assets (fallback) | +| `/ghost/*` | Admin dev server (port 5174) | Admin interface | | Everything else | Ghost backend | Main Ghost application | **Note:** All port numbers listed are the host ports where dev servers run by default. diff --git a/docker/ghost-dev/entrypoint.sh b/docker/ghost-dev/entrypoint.sh index e3e1899d354..5cb1b33e353 100755 --- a/docker/ghost-dev/entrypoint.sh +++ b/docker/ghost-dev/entrypoint.sh @@ -17,6 +17,18 @@ else echo "WARNING: Tinybird not enabled: .env.tinybird file not found at /mnt/shared-config/.env.tinybird" >&2 fi + +# Configure Stripe webhook secret +if [ -f /mnt/shared-config/.env.stripe ]; then + source /mnt/shared-config/.env.stripe + if [ -n "${STRIPE_WEBHOOK_SECRET:-}" ]; then + export WEBHOOK_SECRET="$STRIPE_WEBHOOK_SECRET" + echo "Stripe webhook secret configured successfully" + else + echo "WARNING: Stripe webhook secret not found in shared config" + fi +fi + # Execute the CMD exec "$@" diff --git a/docker/stripe/entrypoint.sh b/docker/stripe/entrypoint.sh index a653f90be64..1055fc49393 100755 --- a/docker/stripe/entrypoint.sh +++ b/docker/stripe/entrypoint.sh @@ -87,8 +87,8 @@ else fi # Start stripe listen in the background -echo "Starting Stripe webhook listener forwarding to http://server:2368/members/webhooks/stripe/" -stripe listen --forward-to http://server:2368/members/webhooks/stripe/ --api-key "${STRIPE_SECRET_KEY}" & +echo "Starting Stripe webhook listener forwarding to ${GHOST_URL}/members/webhooks/stripe/" +stripe listen --forward-to ${GHOST_URL}/members/webhooks/stripe/ --api-key "${STRIPE_SECRET_KEY}" & child=$! # Wait for the child process diff --git a/e2e/data-factory/factories/automated-email-factory.ts b/e2e/data-factory/factories/automated-email-factory.ts index 090105cbc2a..bb9b42ec9b8 100644 --- a/e2e/data-factory/factories/automated-email-factory.ts +++ b/e2e/data-factory/factories/automated-email-factory.ts @@ -26,7 +26,7 @@ export class AutomatedEmailFactory extends Factory, Auto status: 'active', name: 'Welcome Email (Free)', slug: 'member-welcome-email-free', - subject: 'Welcome to {{site.title}}!', + subject: 'Welcome to {site_title}!', lexical: JSON.stringify(this.defaultLexicalContent()), sender_name: null, sender_email: null, @@ -45,7 +45,7 @@ export class AutomatedEmailFactory extends Factory, Auto type: 'paragraph', children: [{ type: 'text', - text: 'Welcome to {{site.title}}!' + text: 'Welcome to {site_title}!' }] }], direction: null, diff --git a/ghost/core/core/server/data/migrations/versions/6.13/2026-01-14-12-56-03-add-redemption-type-to-offers.js b/ghost/core/core/server/data/migrations/versions/6.13/2026-01-14-12-56-03-add-redemption-type-to-offers.js new file mode 100644 index 00000000000..a754f4c352d --- /dev/null +++ b/ghost/core/core/server/data/migrations/versions/6.13/2026-01-14-12-56-03-add-redemption-type-to-offers.js @@ -0,0 +1,8 @@ +const {createAddColumnMigration} = require('../../utils'); + +module.exports = createAddColumnMigration('offers', 'redemption_type', { + type: 'string', + maxlength: 50, + nullable: false, + defaultTo: 'signup' +}); diff --git a/ghost/core/core/server/data/schema/schema.js b/ghost/core/core/server/data/schema/schema.js index 6e45014362d..0211876e4c6 100644 --- a/ghost/core/core/server/data/schema/schema.js +++ b/ghost/core/core/server/data/schema/schema.js @@ -492,6 +492,7 @@ module.exports = { duration_in_months: {type: 'integer', nullable: true}, portal_title: {type: 'string', maxlength: 191, nullable: true}, portal_description: {type: 'string', maxlength: 2000, nullable: true}, + redemption_type: {type: 'string', maxlength: 50, nullable: false, defaultTo: 'signup', validations: {isIn: [['signup', 'retention']]}}, created_at: {type: 'dateTime', nullable: false}, updated_at: {type: 'dateTime', nullable: true} }, diff --git a/ghost/core/core/server/models/offer.js b/ghost/core/core/server/models/offer.js index 1f81b4b35b2..f0fcfb9dea3 100644 --- a/ghost/core/core/server/models/offer.js +++ b/ghost/core/core/server/models/offer.js @@ -6,6 +6,10 @@ const Offer = ghostBookshelf.Model.extend({ actionsCollectCRUD: true, actionsResourceType: 'offer', + defaults: { + redemption_type: 'signup' + }, + product() { return this.belongsTo('Product', 'product_id', 'id'); } diff --git a/ghost/core/core/server/services/member-welcome-emails/constants.js b/ghost/core/core/server/services/member-welcome-emails/constants.js index 3d1881b9e8d..14243162412 100644 --- a/ghost/core/core/server/services/member-welcome-emails/constants.js +++ b/ghost/core/core/server/services/member-welcome-emails/constants.js @@ -24,7 +24,7 @@ const DEFAULT_PAID_LEXICAL_CONTENT = '{"root":{"children":[{"children":[{"detail const DEFAULT_WELCOME_EMAILS = { free: { lexical: DEFAULT_FREE_LEXICAL_CONTENT, - subject: 'Welcome to {{site.title}}', + subject: 'Welcome to {site_title}', status: 'active' }, paid: { diff --git a/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js b/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js index a0f7d91154f..a17430029c7 100644 --- a/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js +++ b/ghost/core/core/server/services/member-welcome-emails/member-welcome-email-renderer.js @@ -5,6 +5,10 @@ const juice = require('juice'); const lexicalLib = require('../../lib/lexical'); const errors = require('@tryghost/errors'); const {MESSAGES} = require('./constants'); +const {wrapReplacementStrings} = require('../koenig/render-utils/replacement-strings'); + +const REPLACEMENT_REGEX = /%%\{(\w+?)(?:,? *"(.*?)")?\}%%/g; +const UNMATCHED_TOKEN_REGEX = /%%\{.*?\}%%/g; class MemberWelcomeEmailRenderer { #wrapperTemplate; @@ -18,6 +22,60 @@ class MemberWelcomeEmailRenderer { this.#wrapperTemplate = this.Handlebars.compile(wrapperSource); } + /** + * Builds replacement token definitions for member and site data + * @param {Object} options + * @param {Object} options.member - Member data + * @param {string} [options.member.name] - Member's full name + * @param {string} [options.member.email] - Member's email address + * @param {Object} options.siteSettings - Site settings + * @param {string} options.siteSettings.title - Site title + * @param {string} options.siteSettings.url - Site URL + * @returns {{id: string, getValue: () => string|undefined}[]} + */ + #buildReplacementDefinitions({member, siteSettings}) { + return [ + {id: 'first_name', getValue: () => { + const name = member.name?.trim(); + if (!name) { + return undefined; + } + return name.split(/\s+/)[0]; + }}, + {id: 'name', getValue: () => member.name}, + {id: 'email', getValue: () => member.email}, + {id: 'site_title', getValue: () => siteSettings.title}, + {id: 'site_url', getValue: () => siteSettings.url} + ]; + } + + /** + * Applies replacement tokens to a string + * Supports fallback values: {first_name, "friend"} renders "friend" if name is empty + * @param {Object} options + * @param {string} options.text - The text to process (content body or subject line) + * @param {Object} options.member - Member data + * @param {Object} options.siteSettings - Site settings + * @param {boolean} [options.escapeHtml=false] - Whether to HTML-escape replaced values + * @returns {string} + */ + #applyReplacements({text, member, siteSettings, escapeHtml = false}) { + const definitions = this.#buildReplacementDefinitions({member, siteSettings}); + let processed = wrapReplacementStrings(text); + + processed = processed.replace(REPLACEMENT_REGEX, (match, property, fallback) => { + const def = definitions.find(d => d.id === property); + if (def) { + const raw = def.getValue(); + const resolved = raw || fallback || ''; + return escapeHtml ? this.Handlebars.Utils.escapeExpression(resolved) : resolved; + } + return match; + }); + + return processed.replace(UNMATCHED_TOKEN_REGEX, ''); + } + /** * Renders a member welcome email * @param {Object} options @@ -38,31 +96,15 @@ class MemberWelcomeEmailRenderer { }); } - const memberName = member.name || 'there'; - const firstName = memberName.split(' ')[0]; + const contentWithReplacements = this.#applyReplacements({text: content, member, siteSettings, escapeHtml: true}); + const subjectWithReplacements = this.#applyReplacements({text: subject, member, siteSettings, escapeHtml: false}); - const templateData = { - site: { - title: siteSettings.title, - url: siteSettings.url - }, - member: { - name: memberName, - email: member.email || '', - firstname: firstName - }, + const html = this.#wrapperTemplate({ + content: contentWithReplacements, + subject: subjectWithReplacements, siteTitle: siteSettings.title, siteUrl: siteSettings.url, accentColor: siteSettings.accentColor - }; - - const contentWithReplacements = this.Handlebars.compile(content)(templateData); - const subjectWithReplacements = this.Handlebars.compile(subject)(templateData); - - const html = this.#wrapperTemplate({ - ...templateData, - content: contentWithReplacements, - subject: subjectWithReplacements }); const inlinedHtml = juice(html, {inlinePseudoElements: true, removeStyleTags: true}); diff --git a/ghost/core/test/integration/jobs/process-outbox.test.js b/ghost/core/test/integration/jobs/process-outbox.test.js index d60f4748e2d..8a72ab2b686 100644 --- a/ghost/core/test/integration/jobs/process-outbox.test.js +++ b/ghost/core/test/integration/jobs/process-outbox.test.js @@ -49,7 +49,7 @@ describe('Process Outbox Job', function () { status: 'active', name: 'Free Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.free, - subject: 'Welcome to {{site.title}}', + subject: 'Welcome to {site_title}', lexical, created_at: new Date() }); diff --git a/ghost/core/test/integration/services/member-welcome-emails.test.js b/ghost/core/test/integration/services/member-welcome-emails.test.js index 4829e282870..4939ae8ddf9 100644 --- a/ghost/core/test/integration/services/member-welcome-emails.test.js +++ b/ghost/core/test/integration/services/member-welcome-emails.test.js @@ -42,7 +42,7 @@ describe('Member Welcome Emails Integration', function () { status: 'active', name: 'Free Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.free, - subject: 'Welcome to {{site.title}}', + subject: 'Welcome to {site_title}', lexical, created_at: new Date() }); @@ -52,7 +52,7 @@ describe('Member Welcome Emails Integration', function () { status: 'active', name: 'Paid Member Welcome Email', slug: MEMBER_WELCOME_EMAIL_SLUGS.paid, - subject: 'Welcome paid member to {{site.title}}', + subject: 'Welcome paid member to {site_title}', lexical, created_at: new Date() }); diff --git a/ghost/core/test/unit/server/data/schema/integrity.test.js b/ghost/core/test/unit/server/data/schema/integrity.test.js index a70e48fe9cc..e68e971f6ef 100644 --- a/ghost/core/test/unit/server/data/schema/integrity.test.js +++ b/ghost/core/test/unit/server/data/schema/integrity.test.js @@ -35,7 +35,7 @@ const validateRouteSettings = require('../../../../../core/server/services/route */ describe('DB version integrity', function () { // Only these variables should need updating - const currentSchemaHash = '840147221764a9de1b3807e3abb61df2'; + const currentSchemaHash = '5d8ff12fd51267005bff200abc420bb0'; const currentFixturesHash = 'c583f33910bb84a70847303b323be2db'; const currentSettingsHash = '64007c832bc27eefbe4d0ed3f888eeab'; const currentRoutesHash = '3d180d52c663d173a6be791ef411ed01'; diff --git a/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js b/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js index e5f665dbef5..5bdcb81f693 100644 --- a/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js +++ b/ghost/core/test/unit/server/services/member-welcome-emails/member-welcome-email-renderer.test.js @@ -43,7 +43,7 @@ describe('MemberWelcomeEmailRenderer', function () { }); it('substitutes member template variables', async function () { - lexicalRenderStub.resolves('

Hello {{member.name}}, or {{member.firstname}}! Contact: {{member.email}}

'); + lexicalRenderStub.resolves('

Hello {name}, or {first_name}! Contact: {email}

'); const renderer = new MemberWelcomeEmailRenderer(); const result = await renderer.render({ @@ -59,7 +59,7 @@ describe('MemberWelcomeEmailRenderer', function () { }); it('substitutes site template variables', async function () { - lexicalRenderStub.resolves('

Welcome to {{site.title}} at {{site.url}}. Also {{siteTitle}} and {{siteUrl}}

'); + lexicalRenderStub.resolves('

Welcome to {site_title} at {site_url}

'); const renderer = new MemberWelcomeEmailRenderer(); const result = await renderer.render({ @@ -71,8 +71,6 @@ describe('MemberWelcomeEmailRenderer', function () { result.html.should.containEql('Welcome to Test Site'); result.html.should.containEql('at https://example.com'); - result.html.should.containEql('Also Test Site'); - result.html.should.containEql('and https://example.com'); }); it('inlines accentColor into link styles', async function () { @@ -94,7 +92,7 @@ describe('MemberWelcomeEmailRenderer', function () { const result = await renderer.render({ lexical: '{}', - subject: 'Welcome to {{site.title}}, {{member.firstname}}!', + subject: 'Welcome to {site_title}, {first_name}!', member: {name: 'John Doe', email: 'john@example.com'}, siteSettings: defaultSiteSettings }); @@ -102,8 +100,23 @@ describe('MemberWelcomeEmailRenderer', function () { result.subject.should.equal('Welcome to Test Site, John!'); }); - it('falls back to "there" when member name is missing', async function () { - lexicalRenderStub.resolves('

Hello {{member.name}}

'); + it('renders empty when member name is missing and no fallback specified', async function () { + lexicalRenderStub.resolves('

Hello {name}!

'); + const renderer = new MemberWelcomeEmailRenderer(); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Welcome!', + member: {email: 'john@example.com'}, + siteSettings: defaultSiteSettings + }); + + result.text.should.containEql('Hello !'); + result.html.should.not.containEql('{name}'); + }); + + it('uses custom fallback when member name is missing', async function () { + lexicalRenderStub.resolves('

Hello {name, "there"}

'); const renderer = new MemberWelcomeEmailRenderer(); const result = await renderer.render({ @@ -116,8 +129,36 @@ describe('MemberWelcomeEmailRenderer', function () { result.html.should.containEql('Hello there'); }); - it('falls back to empty string when member email is missing', async function () { - lexicalRenderStub.resolves('

Email: {{member.email}}

'); + it('uses custom fallback for first_name when missing', async function () { + lexicalRenderStub.resolves('

Hey {first_name, "friend"}

'); + const renderer = new MemberWelcomeEmailRenderer(); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Welcome!', + member: {email: 'john@example.com'}, + siteSettings: defaultSiteSettings + }); + + result.html.should.containEql('Hey friend'); + }); + + it('ignores fallback when member name is present', async function () { + lexicalRenderStub.resolves('

Hello {name, "there"}

'); + const renderer = new MemberWelcomeEmailRenderer(); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Welcome!', + member: {name: 'Jane Smith', email: 'jane@example.com'}, + siteSettings: defaultSiteSettings + }); + + result.html.should.containEql('Hello Jane Smith'); + }); + + it('renders empty when member email is missing', async function () { + lexicalRenderStub.resolves('

Email: {email}!

'); const renderer = new MemberWelcomeEmailRenderer(); const result = await renderer.render({ @@ -127,11 +168,12 @@ describe('MemberWelcomeEmailRenderer', function () { siteSettings: defaultSiteSettings }); - result.html.should.containEql('Email: <'); + result.text.should.containEql('Email: !'); + result.html.should.not.containEql('{email}'); }); it('extracts first name correctly from full name', async function () { - lexicalRenderStub.resolves('

Hi {{member.firstname}}

'); + lexicalRenderStub.resolves('

Hi {first_name}

'); const renderer = new MemberWelcomeEmailRenderer(); const result = await renderer.render({ @@ -144,6 +186,27 @@ describe('MemberWelcomeEmailRenderer', function () { result.html.should.containEql('Hi John'); }); + it('handles whitespace in name when extracting first_name', async function () { + lexicalRenderStub.resolves('

Hi {first_name, "friend"}

'); + const renderer = new MemberWelcomeEmailRenderer(); + + const paddedResult = await renderer.render({ + lexical: '{}', + subject: 'Welcome!', + member: {name: ' Jane Doe ', email: 'jane@example.com'}, + siteSettings: defaultSiteSettings + }); + paddedResult.html.should.containEql('Hi Jane'); + + const emptyResult = await renderer.render({ + lexical: '{}', + subject: 'Welcome!', + member: {name: ' ', email: 'empty@example.com'}, + siteSettings: defaultSiteSettings + }); + emptyResult.html.should.containEql('Hi friend'); + }); + it('wraps content in wrapper.hbs template', async function () { lexicalRenderStub.resolves('

Content

'); const renderer = new MemberWelcomeEmailRenderer(); @@ -205,5 +268,37 @@ describe('MemberWelcomeEmailRenderer', function () { err.context.should.equal('Parse error'); } }); + + it('escapes HTML in member values for body but not subject', async function () { + lexicalRenderStub.resolves('

Hello {name}

'); + const renderer = new MemberWelcomeEmailRenderer(); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Welcome {name}!', + member: {name: '', email: 'test@example.com'}, + siteSettings: defaultSiteSettings + }); + + result.html.should.containEql('<script>alert("xss")</script>'); + result.html.should.not.containEql(''); + result.subject.should.equal('Welcome !'); + }); + + it('removes unknown tokens from output', async function () { + lexicalRenderStub.resolves('

Hello {unknown_token} and {another}

'); + const renderer = new MemberWelcomeEmailRenderer(); + + const result = await renderer.render({ + lexical: '{}', + subject: 'Welcome {invalid}!', + member: {name: 'John', email: 'john@example.com'}, + siteSettings: defaultSiteSettings + }); + + result.html.should.not.containEql('%%{'); + result.html.should.not.containEql('}%%'); + result.subject.should.equal('Welcome !'); + }); }); }); diff --git a/package.json b/package.json index 5e2fb8d9712..866cdf2e874 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "build:clean": "nx reset && rimraf -g 'ghost/*/build' && rimraf -g 'ghost/*/tsconfig.tsbuildinfo'", "clean:hard": "node ./.github/scripts/clean.js", "dev:forward": "nx run ghost-monorepo:docker:dev", + "dev:lexical": "EDITOR_URL=http://localhost:2368/ghost/assets/koenig-lexical/ yarn dev:forward", "dev:analytics": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml' nx run ghost-monorepo:docker:dev", "dev:storage": "DEV_COMPOSE_FILES='-f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev", "dev:all": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml -f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev",