Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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'
Expand All @@ -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)) {
Expand All @@ -55,6 +54,6 @@ module.exports = {
});
}

validatePreviewData(frame);
await validatePreviewData(frame);
}
};
98 changes: 62 additions & 36 deletions ghost/core/core/server/lib/lexical.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function was extracted from render with no changes.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice refactor! 👌

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 {
Expand All @@ -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;
Expand All @@ -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() {
Expand Down
84 changes: 69 additions & 15 deletions ghost/core/core/server/services/automations/automations-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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));
Expand All @@ -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();
Expand Down Expand Up @@ -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);

Expand All @@ -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<EditAutomationData> {
const result = editAutomationDataSchema.safeParse(data);

if (!result.success) {
Expand All @@ -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.
Expand All @@ -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[]) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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\\}/,
Expand All @@ -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\\}/,
Expand Down Expand Up @@ -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 \\]\\|\\\\\\\\\\.\\)\\*"/,
Expand Down
26 changes: 26 additions & 0 deletions ghost/core/test/unit/server/lib/lexical.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,4 +91,30 @@ describe('lib/lexical', function () {
assert(rendered.includes('<span>CUSTOM</span>'));
});
});

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);
});
});
});
Loading
Loading