diff --git a/ghost/core/core/server/api/endpoints/utils/validators/input/automation_email_previews.js b/ghost/core/core/server/api/endpoints/utils/validators/input/automation_email_previews.js index e5a1c77002f..c7549d85747 100644 --- a/ghost/core/core/server/api/endpoints/utils/validators/input/automation_email_previews.js +++ b/ghost/core/core/server/api/endpoints/utils/validators/input/automation_email_previews.js @@ -4,15 +4,16 @@ const validator = require('@tryghost/validator'); const {ValidationError} = require('@tryghost/errors'); const tpl = require('@tryghost/tpl'); +const lexicalLib = require('../../../../../lib/lexical'); const messages = { invalidEmailReceived: 'The server did not receive a valid email', - invalidLexical: 'Lexical must be a valid JSON string', + invalidLexical: 'Lexical must be a well-formed Lexical document', subjectRequired: 'Subject is required', lexicalRequired: 'Email content is required' }; -const validatePreviewData = (frame) => { +const validatePreviewData = async (frame) => { const subject = frame.data.subject; const lexical = frame.data.lexical; @@ -30,9 +31,7 @@ const validatePreviewData = (frame) => { }); } - try { - JSON.parse(lexical); - } catch (e) { + if (!await lexicalLib.validate(lexical)) { throw new ValidationError({ message: tpl(messages.invalidLexical), property: 'lexical' @@ -41,11 +40,11 @@ const validatePreviewData = (frame) => { }; module.exports = { - preview(apiConfig, frame) { - validatePreviewData(frame); + async preview(apiConfig, frame) { + await validatePreviewData(frame); }, - sendTestEmail(apiConfig, frame) { + async sendTestEmail(apiConfig, frame) { const email = frame.data.email; if (typeof email !== 'string' || !validator.isEmail(email)) { @@ -55,6 +54,6 @@ module.exports = { }); } - validatePreviewData(frame); + await validatePreviewData(frame); } }; diff --git a/ghost/core/core/server/lib/lexical.js b/ghost/core/core/server/lib/lexical.js index 199fdf1f08c..562f97d47c9 100644 --- a/ghost/core/core/server/lib/lexical.js +++ b/ghost/core/core/server/lib/lexical.js @@ -18,6 +18,51 @@ function populateNodes() { nodes = DEFAULT_NODES; } +function createLexicalHtmlRenderer(onError) { + if (!nodes) { + populateNodes(); + } + + const {LexicalHTMLRenderer} = require('@tryghost/kg-lexical-html-renderer'); + return new LexicalHTMLRenderer({ + nodes, + onError + }); +} + +function buildRenderOptions(userOptions, nodeRenderers) { + if (!postsService) { + const getPostServiceInstance = require('../services/posts/posts-service-instance'); + postsService = getPostServiceInstance(); + } + if (!serializePosts) { + serializePosts = require('../api/endpoints/utils/serializers/output/posts').all; + } + + return Object.assign({ + siteUuid: settingsCache.get('site_uuid'), + siteUrl: config.get('url'), + imageBaseUrl: config.get('urls:image') || '', + imageOptimization: config.get('imageOptimization'), + canTransformImage(storagePath) { + const imageTransform = require('@tryghost/image-transform'); + const {ext} = path.parse(storagePath); + + // NOTE: the "saveRaw" check is smelly + return imageTransform.canTransformFiles() + && imageTransform.shouldResizeFileExtension(ext) + && typeof storage.getStorage('images').saveRaw === 'function'; + }, + feature: { + contentVisibility: true, // force on until Koenig has been bumped + emailCustomization: true, // force on until Koenig has been bumped + emailUniqueid: labs.isSet('emailUniqueid'), + pictureImageFormats: labs.isSet('pictureImageFormats') + }, + nodeRenderers + }, userOptions); +} + module.exports = { get blankDocument() { return { @@ -43,12 +88,7 @@ module.exports = { get lexicalHtmlRenderer() { if (!lexicalHtmlRenderer) { - if (!nodes) { - populateNodes(); - } - - const {LexicalHTMLRenderer} = require('@tryghost/kg-lexical-html-renderer'); - lexicalHtmlRenderer = new LexicalHTMLRenderer({nodes}); + lexicalHtmlRenderer = createLexicalHtmlRenderer(); } return lexicalHtmlRenderer; @@ -72,38 +112,24 @@ module.exports = { }, async render(lexical, userOptions = {}) { - if (!postsService) { - const getPostServiceInstance = require('../services/posts/posts-service-instance'); - postsService = getPostServiceInstance(); - } - if (!serializePosts) { - serializePosts = require('../api/endpoints/utils/serializers/output/posts').all; - } + const options = buildRenderOptions(userOptions, this.customNodeRenderers); + return await this.lexicalHtmlRenderer.render(lexical, options); + }, - const options = Object.assign({ - siteUuid: settingsCache.get('site_uuid'), - siteUrl: config.get('url'), - imageBaseUrl: config.get('urls:image') || '', - imageOptimization: config.get('imageOptimization'), - canTransformImage(storagePath) { - const imageTransform = require('@tryghost/image-transform'); - const {ext} = path.parse(storagePath); - - // NOTE: the "saveRaw" check is smelly - return imageTransform.canTransformFiles() - && imageTransform.shouldResizeFileExtension(ext) - && typeof storage.getStorage('images').saveRaw === 'function'; - }, - feature: { - contentVisibility: true, // force on until Koenig has been bumped - emailCustomization: true, // force on until Koenig has been bumped - emailUniqueid: labs.isSet('emailUniqueid'), - pictureImageFormats: labs.isSet('pictureImageFormats') - }, - nodeRenderers: this.customNodeRenderers - }, userOptions); + async validate(lexical, userOptions = {}) { + try { + const lexicalValidationRenderer = createLexicalHtmlRenderer((error) => { + throw error; + }); - return await this.lexicalHtmlRenderer.render(lexical, options); + // The validation renderer rethrows parser errors so this method can + // convert every malformed document into a boolean result. + const options = buildRenderOptions(userOptions, this.customNodeRenderers); + await lexicalValidationRenderer.render(lexical, options); + return true; + } catch { + return false; + } }, get nodes() { diff --git a/ghost/core/core/server/services/automations/automations-api.ts b/ghost/core/core/server/services/automations/automations-api.ts index 818dc1d9b37..f87f1f81114 100644 --- a/ghost/core/core/server/services/automations/automations-api.ts +++ b/ghost/core/core/server/services/automations/automations-api.ts @@ -14,6 +14,7 @@ const {knex} = require('../../data/db'); const domainEvents = require('@tryghost/domain-events'); const labs = require('../../../shared/labs'); const config = require('../../../shared/config'); +const lexicalLib = require('../../lib/lexical'); const StartAutomationsPollEvent = require('./events/start-automations-poll-event'); const MAX_AUTOMATION_ACTIONS = 20; @@ -28,7 +29,8 @@ const messages = { invalidAutomationEdge: 'Automation edges cannot connect an action to itself.', invalidAutomationGraphShape: 'Automation graph must be a single linear path without branches or cycles.', emptyEmailSubjectWhenActive: 'Active automations require a subject line for every email.', - emptyEmailBodyWhenActive: 'Active automations require a body for every email.' + emptyEmailBodyWhenActive: 'Active automations require a body for every email.', + invalidEmailLexical: 'Email lexical must be a well-formed Lexical document.' }; const objectIdSchema = z.string().refine(value => ObjectId.isValid(value)); @@ -46,14 +48,7 @@ const sendEmailActionSchema = z.object({ type: z.literal('send_email'), data: z.object({ email_subject: z.string(), - email_lexical: z.string().refine((value) => { - try { - JSON.parse(value); - return true; - } catch { - return false; - } - }), + email_lexical: z.string(), email_design_setting_id: z.string().min(1) }).strict() }).strict(); @@ -94,7 +89,7 @@ export async function read(automationId: string) { } export async function edit(automationId: string, data: unknown) { - const parsedData = validateEditData(data); + const parsedData = await validateEditData(data); const automation = await repository.edit(automationId, parsedData); @@ -107,7 +102,7 @@ export async function edit(automationId: string, data: unknown) { return automation; } -function validateEditData(data: unknown): EditAutomationData { +async function validateEditData(data: unknown): Promise { const result = editAutomationDataSchema.safeParse(data); if (!result.success) { @@ -119,10 +114,35 @@ function validateEditData(data: unknown): EditAutomationData { } validateGraph(result.data.actions, result.data.edges); + await validateEmailLexical(result.data.actions); validateActiveEmailSteps(result.data.status, result.data.actions); return result.data; } +async function validateEmailLexical(actions: EditAutomationData['actions']) { + await Promise.all(actions.map(async (action) => { + if (action.type !== 'send_email') { + return; + } + + const lexical = action.data.email_lexical; + + // Empty editor documents are valid draft state and are classified by + // active-body validation below. Invalid JSON is not skipped here. + if (isValidEmptyLexical(lexical)) { + return; + } + + if (isMalformedEmptyLexical(lexical)) { + throwValidationError(messages.invalidEmailLexical, 'actions'); + } + + if (!await lexicalLib.validate(lexical)) { + throwValidationError(messages.invalidEmailLexical, 'actions'); + } + })); +} + // Drafts may persist empty email steps, but an active automation must have a // complete subject and body for every email it sends — mirroring the editor's // publish-time validation. @@ -149,16 +169,50 @@ function validateActiveEmailSteps(status: EditAutomationData['status'], actions: function isEmptyLexical(lexical: string): boolean { try { const parsed = JSON.parse(lexical); - const children = parsed?.root?.children; + return isEmptyParsedLexical(parsed); + } catch { + return true; + } +} - if (!children || children.length === 0) { - return true; +function isValidEmptyLexical(lexical: string): boolean { + try { + return isEmptyParsedLexical(JSON.parse(lexical)); + } catch { + return false; + } +} + +function isMalformedEmptyLexical(lexical: string): boolean { + try { + const children = JSON.parse(lexical)?.root?.children; + + if (!Array.isArray(children) || children.length !== 1 || children[0].type !== 'paragraph') { + return false; } - return children.length === 1 && children[0].type === 'paragraph' && (!children[0].children || children[0].children.length === 0); + return !Array.isArray(children[0].children); } catch { + return false; + } +} + +function isEmptyParsedLexical(parsed: {root?: {children?: Array<{type?: string; children?: unknown}>}}): boolean { + const children = parsed?.root?.children; + + if (!Array.isArray(children)) { + return false; + } + + if (children.length === 0) { return true; } + + if (children.length !== 1 || children[0].type !== 'paragraph') { + return false; + } + + return Array.isArray(children[0].children) && children[0].children.length === 0; } function buildInvalidAutomationPayloadMessage(issues: z.core.$ZodIssue[]) { diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap index 3edbd2c8f52..cc74551b99e 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/automations.test.js.snap @@ -147,7 +147,7 @@ Object { Object { "data": Object { "email_design_setting_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "email_lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"paragraph\\",\\"children\\":[{\\"type\\":\\"text\\",\\"text\\":\\"Lorem ipsum.\\"}]}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "email_lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Lorem ipsum.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", "email_subject": "Welcome!", }, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, @@ -163,7 +163,7 @@ Object { Object { "data": Object { "email_design_setting_id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "email_lexical": "{\\"root\\":{\\"children\\":[{\\"type\\":\\"paragraph\\",\\"children\\":[{\\"type\\":\\"text\\",\\"text\\":\\"Lorem ipsum.\\"}]}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", + "email_lexical": "{\\"root\\":{\\"children\\":[{\\"children\\":[{\\"detail\\":0,\\"format\\":0,\\"mode\\":\\"normal\\",\\"style\\":\\"\\",\\"text\\":\\"Lorem ipsum.\\",\\"type\\":\\"text\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"paragraph\\",\\"version\\":1}],\\"direction\\":null,\\"format\\":\\"\\",\\"indent\\":0,\\"type\\":\\"root\\",\\"version\\":1}}", "email_subject": "Follow up", }, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, @@ -199,7 +199,7 @@ exports[`Automations API read returns the automation, ordered actions, and edges Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1379", + "content-length": "1653", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/unit/server/lib/lexical.test.js b/ghost/core/test/unit/server/lib/lexical.test.js index 83e3525007a..799adb8d5f6 100644 --- a/ghost/core/test/unit/server/lib/lexical.test.js +++ b/ghost/core/test/unit/server/lib/lexical.test.js @@ -91,4 +91,30 @@ describe('lib/lexical', function () { assert(rendered.includes('CUSTOM')); }); }); + + describe('validate()', function () { + it('returns true for well-formed lexical', async function () { + const lexical = `{"root":{"children":[{"children":[{"detail":0,"format":0,"mode":"normal","style":"","text":"Lexical is valid.","type":"text","version":1}],"direction":"ltr","format":"","indent":0,"type":"paragraph","version":1}],"direction":"ltr","format":"","indent":0,"type":"root","version":1}}`; + + assert.equal(await lexicalLib.validate(lexical), true); + }); + + it('returns false for malformed lexical', async function () { + const lexical = JSON.stringify({ + root: { + children: [{ + type: 'unknown-node', + version: 1 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } + }); + + assert.equal(await lexicalLib.validate(lexical), false); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/automations/automations-api.test.js b/ghost/core/test/unit/server/services/automations/automations-api.test.js index 74f47ecfe0f..49fcdbe0e02 100644 --- a/ghost/core/test/unit/server/services/automations/automations-api.test.js +++ b/ghost/core/test/unit/server/services/automations/automations-api.test.js @@ -40,5 +40,79 @@ describe('automations API', function () { /body/ ); }); + + it('rejects a send email action with invalid JSON', async function () { + await assert.rejects( + automationsApi.edit(automationId, { + status: 'inactive', + actions: [buildSendEmailAction({email_lexical: '{"root":'})], + edges: [] + }), + /well-formed Lexical document/ + ); + }); + + it('rejects an active send email action with invalid JSON as malformed Lexical', async function () { + await assert.rejects( + automationsApi.edit(automationId, { + status: 'active', + actions: [buildSendEmailAction({email_lexical: '{"root":'})], + edges: [] + }), + /well-formed Lexical document/ + ); + }); + + it('rejects a send email action with JSON that is not a Lexical document', async function () { + await assert.rejects( + automationsApi.edit(automationId, { + status: 'inactive', + actions: [buildSendEmailAction({email_lexical: JSON.stringify({children: []})})], + edges: [] + }), + /well-formed Lexical document/ + ); + }); + + it('rejects a draft send email action with a malformed empty paragraph', async function () { + await assert.rejects( + automationsApi.edit(automationId, { + status: 'inactive', + actions: [buildSendEmailAction({ + email_lexical: JSON.stringify({ + root: { + children: [{ + type: 'paragraph', + version: 1 + }], + type: 'root', + version: 1 + } + }) + })], + edges: [] + }), + /well-formed Lexical document/ + ); + }); + + it('rejects a send email action with malformed Lexical child nodes', async function () { + await assert.rejects( + automationsApi.edit(automationId, { + status: 'inactive', + actions: [buildSendEmailAction({ + email_lexical: JSON.stringify({ + root: { + children: [{type: 'unknown-node', version: 1}], + type: 'root', + version: 1 + } + }) + })], + edges: [] + }), + /well-formed Lexical document/ + ); + }); }); }); diff --git a/ghost/core/test/utils/automations-fixtures.ts b/ghost/core/test/utils/automations-fixtures.ts index a73cd05650d..a2442ba925f 100644 --- a/ghost/core/test/utils/automations-fixtures.ts +++ b/ghost/core/test/utils/automations-fixtures.ts @@ -16,7 +16,21 @@ export const EMPTY_EMAIL_LEXICAL = JSON.stringify({ }); export const NON_EMPTY_EMAIL_LEXICAL = JSON.stringify({ - root: {children: [{type: 'paragraph', children: [{type: 'text', text: 'Lorem ipsum.'}]}], direction: null, format: '', indent: 0, type: 'root', version: 1} + root: { + children: [{ + children: [{detail: 0, format: 0, mode: 'normal', style: '', text: 'Lorem ipsum.', type: 'text', version: 1}], + direction: null, + format: '', + indent: 0, + type: 'paragraph', + version: 1 + }], + direction: null, + format: '', + indent: 0, + type: 'root', + version: 1 + } }); const timestamp = (offset: number): string => moment(new Date(Date.UTC(2026, 0, 1, 0, 0, offset))).format('YYYY-MM-DD HH:mm:ss');