0
Fork 0
mirror of https://github.com/TryGhost/Ghost.git synced 2025-03-18 02:21:47 -05:00

Added test that validates output HTML of email template (#15365)

refs https://github.com/TryGhost/Team/issues/1871

This commit adds a test to the serialize method of `post-emaiserializer`. It checks whether the generated email HTML is valid and standard HTML5 and that all properties are escaped.

To do this validation, I depend on the new `html-validate` dev dependency. Just parsing the HTML with a HTML parser is not enough to guarantee that the HTML is okay.

Apart from that this fixes:
- Removed the sanitizeHTML method and replaced it with normal HTML escaping. We don't want to allow any HTML in the escaped fields. Whereas `sanitizeHTML` still allows valid HTML, but we don't want that and want the same behaviour as on the site. E.g., a post with a title `All your need to know about the <br /> tag` should actually render the same title and non-html content, being `All your need to know about the &lt;br /&gt; tag`
- The file, nft and audio card didn't (always) escape the injected HTML fields (new version @tryghost/kg-default-cards) 
- `@tryghost/string` is bumped because it contains the new escapeHtml method
This commit is contained in:
Simon Backx 2022-09-08 10:11:01 +02:00 committed by GitHub
parent 11cbfcb0b6
commit 4534b693e4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 684 additions and 520 deletions

View file

@ -44,14 +44,14 @@
"@tryghost/color-utils": "0.1.20",
"@tryghost/ember-promise-modals": "2.0.1",
"@tryghost/helpers": "1.1.72",
"@tryghost/kg-clean-basic-html": "2.2.14",
"@tryghost/kg-parser-plugins": "2.12.0",
"@tryghost/kg-clean-basic-html": "2.2.16",
"@tryghost/kg-parser-plugins": "2.12.1",
"@tryghost/limit-service": "1.2.3",
"@tryghost/members-csv": "0.0.0",
"@tryghost/mobiledoc-kit": "0.12.5-ghost.2",
"@tryghost/nql": "0.9.2",
"@tryghost/nql-lang": "0.3.2",
"@tryghost/string": "0.1.27",
"@tryghost/string": "0.2.0",
"@tryghost/timezone-data": "0.2.71",
"autoprefixer": "9.8.6",
"babel-eslint": "10.1.0",
@ -174,4 +174,4 @@
"path-browserify": "1.0.1",
"webpack": "5.74.0"
}
}
}

View file

@ -17,427 +17,430 @@ const urlService = require('../../services/url');
const ALLOWED_REPLACEMENTS = ['first_name'];
// Format a full html document ready for email by inlining CSS, adjusting links,
// and performing any client-specific fixes
const formatHtmlForEmail = function formatHtmlForEmail(html) {
const juiceOptions = {inlinePseudoElements: true};
const PostEmailSerializer = {
// Format a full html document ready for email by inlining CSS, adjusting links,
// and performing any client-specific fixes
formatHtmlForEmail(html) {
const juiceOptions = {inlinePseudoElements: true};
const juice = require('juice');
let juicedHtml = juice(html, juiceOptions);
const juice = require('juice');
let juicedHtml = juice(html, juiceOptions);
// convert juiced HTML to a DOM-like interface for further manipulation
// happens after inlining of CSS so we can change element types without worrying about styling
// convert juiced HTML to a DOM-like interface for further manipulation
// happens after inlining of CSS so we can change element types without worrying about styling
const cheerio = require('cheerio');
const _cheerio = cheerio.load(juicedHtml);
const cheerio = require('cheerio');
const _cheerio = cheerio.load(juicedHtml);
// force all links to open in new tab
_cheerio('a').attr('target', '_blank');
// convert figure and figcaption to div so that Outlook applies margins
_cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
// force all links to open in new tab
_cheerio('a').attr('target', '_blank');
// convert figure and figcaption to div so that Outlook applies margins
_cheerio('figure, figcaption').each((i, elem) => !!(elem.tagName = 'div'));
juicedHtml = _cheerio.html();
juicedHtml = _cheerio.html();
// Fix any unsupported chars in Outlook
juicedHtml = juicedHtml.replace(/&apos;/g, '&#39;');
// Fix any unsupported chars in Outlook
juicedHtml = juicedHtml.replace(/&apos;/g, '&#39;');
return juicedHtml;
};
return juicedHtml;
},
const getSite = () => {
const publicSettings = settingsCache.getPublic();
return Object.assign({}, publicSettings, {
url: urlUtils.urlFor('home', true),
iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
});
};
/**
* createUnsubscribeUrl
*
* Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
* In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
*
* @param {string} uuid post uuid
* @param {Object} [options]
* @param {string} [options.newsletterUuid] newsletter uuid
* @param {boolean} [options.comments] Unsubscribe from comment emails
*/
const createUnsubscribeUrl = (uuid, options = {}) => {
const siteUrl = urlUtils.getSiteUrl();
const unsubscribeUrl = new URL(siteUrl);
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
if (uuid) {
unsubscribeUrl.searchParams.set('uuid', uuid);
} else {
unsubscribeUrl.searchParams.set('preview', '1');
}
if (options.newsletterUuid) {
unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
}
if (options.comments) {
unsubscribeUrl.searchParams.set('comments', '1');
}
return unsubscribeUrl.href;
};
/**
* createPostSignupUrl
*
* Takes a post object. Returns the url that should be used to signup from newsletter
*
* @param {Object} post post object
*/
const createPostSignupUrl = (post) => {
let url = urlService.getUrlByResourceId(post.id, {absolute: true});
// For email-only posts, use site url as base
if (post.status !== 'published' && url.match(/\/404\//)) {
url = urlUtils.getSiteUrl();
}
const signupUrl = new URL(url);
signupUrl.hash = `/portal/signup`;
return signupUrl.href;
};
// NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
const serializePostModel = async (model) => {
// fetch mobiledoc rather than html and plaintext so we can render email-specific contents
const frame = {options: {context: {user: true}, formats: 'mobiledoc'}};
const docName = 'posts';
await apiFramework
.serializers
.handle
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
return frame.response[docName][0];
};
// removes %% wrappers from unknown replacement strings in email content
const normalizeReplacementStrings = (email) => {
// we don't want to modify the email object in-place
const emailContent = _.pick(email, ['html', 'plaintext']);
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
['html', 'plaintext'].forEach((format) => {
emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
if (match) {
const {recipientProperty} = match.groups;
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
// keeps wrapping %% for later replacement with real data
return replacementMatch;
}
}
// removes %% so output matches user supplied content
return replacementStr;
getSite() {
const publicSettings = settingsCache.getPublic();
return Object.assign({}, publicSettings, {
url: urlUtils.urlFor('home', true),
iconUrl: publicSettings.icon ? urlUtils.urlFor('image', {image: publicSettings.icon}, true) : null
});
});
},
return emailContent;
};
/**
* Parses email content and extracts an array of replacements with desired fallbacks
*
* @param {Object} email
* @param {string} email.html
* @param {string} email.plaintext
*
* @returns {Object[]} replacements
*/
const parseReplacements = (email) => {
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
const replacements = [];
['html', 'plaintext'].forEach((format) => {
let result;
while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
const [replacementMatch, replacementStr] = result;
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
if (match) {
const {recipientProperty, fallback} = match.groups;
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
const id = `replacement_${replacements.length + 1}`;
replacements.push({
format,
id,
match: replacementMatch,
recipientProperty: `member_${recipientProperty}`,
fallback
});
}
}
}
});
return replacements;
};
const getTemplateSettings = async (newsletter) => {
const accentColor = settingsCache.get('accent_color');
const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
const templateSettings = {
headerImage: newsletter.get('header_image'),
showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
showHeaderTitle: newsletter.get('show_header_title'),
showFeatureImage: newsletter.get('show_feature_image'),
titleFontCategory: newsletter.get('title_font_category'),
titleAlignment: newsletter.get('title_alignment'),
bodyFontCategory: newsletter.get('body_font_category'),
showBadge: newsletter.get('show_badge'),
footerContent: newsletter.get('footer_content'),
showHeaderName: newsletter.get('show_header_name'),
accentColor,
adjustedAccentColor,
adjustedAccentContrastColor
};
if (templateSettings.headerImage) {
if (isUnsplashImage(templateSettings.headerImage)) {
// Unsplash images have a minimum size so assuming 1200px is safe
const unsplashUrl = new URL(templateSettings.headerImage);
unsplashUrl.searchParams.set('w', '1200');
templateSettings.headerImage = unsplashUrl.href;
templateSettings.headerImageWidth = 600;
/**
* createUnsubscribeUrl
*
* Takes a member and newsletter uuid. Returns the url that should be used to unsubscribe
* In case of no member uuid, generates the preview unsubscribe url - `?preview=1`
*
* @param {string} uuid post uuid
* @param {Object} [options]
* @param {string} [options.newsletterUuid] newsletter uuid
* @param {boolean} [options.comments] Unsubscribe from comment emails
*/
createUnsubscribeUrl(uuid, options = {}) {
const siteUrl = urlUtils.getSiteUrl();
const unsubscribeUrl = new URL(siteUrl);
unsubscribeUrl.pathname = `${unsubscribeUrl.pathname}/unsubscribe/`.replace('//', '/');
if (uuid) {
unsubscribeUrl.searchParams.set('uuid', uuid);
} else {
const {imageSize} = require('../../lib/image');
try {
const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
unsubscribeUrl.searchParams.set('preview', '1');
}
if (options.newsletterUuid) {
unsubscribeUrl.searchParams.set('newsletter', options.newsletterUuid);
}
if (options.comments) {
unsubscribeUrl.searchParams.set('comments', '1');
}
if (size.width >= 600) {
// keep original image, just set a fixed width
templateSettings.headerImageWidth = 600;
return unsubscribeUrl.href;
},
/**
* createPostSignupUrl
*
* Takes a post object. Returns the url that should be used to signup from newsletter
*
* @param {Object} post post object
*/
createPostSignupUrl(post) {
let url = urlService.getUrlByResourceId(post.id, {absolute: true});
// For email-only posts, use site url as base
if (post.status !== 'published' && url.match(/\/404\//)) {
url = urlUtils.getSiteUrl();
}
const signupUrl = new URL(url);
signupUrl.hash = `/portal/signup`;
return signupUrl.href;
},
// NOTE: serialization is needed to make sure we do post transformations such as image URL transformation from relative to absolute
async serializePostModel(model) {
// fetch mobiledoc rather than html and plaintext so we can render email-specific contents
const frame = {options: {context: {user: true}, formats: 'mobiledoc'}};
const docName = 'posts';
await apiFramework
.serializers
.handle
.output(model, {docName: docName, method: 'read'}, api.serializers.output, frame);
return frame.response[docName][0];
},
// removes %% wrappers from unknown replacement strings in email content
normalizeReplacementStrings(email) {
// we don't want to modify the email object in-place
const emailContent = _.pick(email, ['html', 'plaintext']);
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
['html', 'plaintext'].forEach((format) => {
emailContent[format] = emailContent[format].replace(EMAIL_REPLACEMENT_REGEX, (replacementMatch, replacementStr) => {
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
if (match) {
const {recipientProperty} = match.groups;
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
// keeps wrapping %% for later replacement with real data
return replacementMatch;
}
}
if (isLocalContentImage(templateSettings.headerImage, urlUtils.getSiteUrl())) {
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
// removes %% so output matches user supplied content
return replacementStr;
});
});
return emailContent;
},
/**
* Parses email content and extracts an array of replacements with desired fallbacks
*
* @param {Object} email
* @param {string} email.html
* @param {string} email.plaintext
*
* @returns {Object[]} replacements
*/
parseReplacements(email) {
const EMAIL_REPLACEMENT_REGEX = /%%(\{.*?\})%%/g;
const REPLACEMENT_STRING_REGEX = /\{(?<recipientProperty>\w*?)(?:,? *(?:"|&quot;)(?<fallback>.*?)(?:"|&quot;))?\}/;
const replacements = [];
['html', 'plaintext'].forEach((format) => {
let result;
while ((result = EMAIL_REPLACEMENT_REGEX.exec(email[format])) !== null) {
const [replacementMatch, replacementStr] = result;
const match = replacementStr.match(REPLACEMENT_STRING_REGEX);
if (match) {
const {recipientProperty, fallback} = match.groups;
if (ALLOWED_REPLACEMENTS.includes(recipientProperty)) {
const id = `replacement_${replacements.length + 1}`;
replacements.push({
format,
id,
match: replacementMatch,
recipientProperty: `member_${recipientProperty}`,
fallback
});
}
}
}
});
return replacements;
},
async getTemplateSettings(newsletter) {
const accentColor = settingsCache.get('accent_color');
const adjustedAccentColor = accentColor && darkenToContrastThreshold(accentColor, '#ffffff', 2).hex();
const adjustedAccentContrastColor = accentColor && textColorForBackgroundColor(adjustedAccentColor).hex();
const templateSettings = {
headerImage: newsletter.get('header_image'),
showHeaderIcon: newsletter.get('show_header_icon') && settingsCache.get('icon'),
showHeaderTitle: newsletter.get('show_header_title'),
showFeatureImage: newsletter.get('show_feature_image'),
titleFontCategory: newsletter.get('title_font_category'),
titleAlignment: newsletter.get('title_alignment'),
bodyFontCategory: newsletter.get('body_font_category'),
showBadge: newsletter.get('show_badge'),
footerContent: newsletter.get('footer_content'),
showHeaderName: newsletter.get('show_header_name'),
accentColor,
adjustedAccentColor,
adjustedAccentContrastColor
};
if (templateSettings.headerImage) {
if (isUnsplashImage(templateSettings.headerImage)) {
// Unsplash images have a minimum size so assuming 1200px is safe
const unsplashUrl = new URL(templateSettings.headerImage);
unsplashUrl.searchParams.set('w', '1200');
templateSettings.headerImage = unsplashUrl.href;
templateSettings.headerImageWidth = 600;
} else {
const {imageSize} = require('../../lib/image');
try {
const size = await imageSize.getImageSizeFromUrl(templateSettings.headerImage);
if (size.width >= 600) {
// keep original image, just set a fixed width
templateSettings.headerImageWidth = 600;
}
if (isLocalContentImage(templateSettings.headerImage, urlUtils.getSiteUrl())) {
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
templateSettings.headerImage = templateSettings.headerImage.replace(/\/content\/images\//, '/content/images/size/w1200/');
}
} catch (err) {
// log and proceed. Using original header image without fixed width isn't fatal.
logging.error(err);
}
} catch (err) {
// log and proceed. Using original header image without fixed width isn't fatal.
logging.error(err);
}
}
}
return templateSettings;
return templateSettings;
},
async serialize(postModel, newsletter, options = {isBrowserPreview: false}) {
const post = await this.serializePostModel(postModel);
const timezone = settingsCache.get('timezone');
const momentDate = post.published_at ? moment(post.published_at) : moment();
post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
if (post.authors) {
if (post.authors.length <= 2) {
post.authors = post.authors.map(author => author.name).join(' & ');
} else if (post.authors.length > 2) {
post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
}
}
if (post.posts_meta) {
post.email_subject = post.posts_meta.email_subject;
}
// we use post.excerpt as a hidden piece of text that is picked up by some email
// clients as a "preview" when listing emails. Our current plaintext/excerpt
// generation outputs links as "Link [https://url/]" which isn't desired in the preview
if (!post.custom_excerpt && post.excerpt) {
post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
}
post.html = mobiledocLib.mobiledocHtmlRenderer.render(
JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
);
// perform any email specific adjustments to the mobiledoc->HTML render output
// body wrapper is required so we can get proper top-level selections
const cheerio = require('cheerio');
let _cheerio = cheerio.load(`<body>${post.html}</body>`);
// remove leading/trailing HRs
_cheerio(`
body > hr:first-child,
body > hr:last-child,
body > div:first-child > hr:first-child,
body > div:last-child > hr:last-child
`).remove();
post.html = _cheerio('body').html();
post.plaintext = htmlToPlaintext.email(post.html);
// Outlook will render feature images at full-size breaking the layout.
// Content images fix this by rendering max 600px images - do the same for feature image here
if (post.feature_image) {
if (isUnsplashImage(post.feature_image)) {
// Unsplash images have a minimum size so assuming 1200px is safe
const unsplashUrl = new URL(post.feature_image);
unsplashUrl.searchParams.set('w', '1200');
post.feature_image = unsplashUrl.href;
post.feature_image_width = 600;
} else {
const {imageSize} = require('../../lib/image');
try {
const size = await imageSize.getImageSizeFromUrl(post.feature_image);
if (size.width >= 600) {
// keep original image, just set a fixed width
post.feature_image_width = 600;
}
if (isLocalContentImage(post.feature_image, urlUtils.getSiteUrl())) {
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
}
} catch (err) {
// log and proceed. Using original feature_image without fixed width isn't fatal.
logging.error(err);
}
}
}
const templateSettings = await this.getTemplateSettings(newsletter);
const render = template;
let htmlTemplate = render({post, site: this.getSite(), templateSettings, newsletter: newsletter.toJSON()});
if (options.isBrowserPreview) {
const previewUnsubscribeUrl = this.createUnsubscribeUrl(null);
htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
}
// Clean up any unknown replacements strings to get our final content
const {html, plaintext} = this.normalizeReplacementStrings({
html: this.formatHtmlForEmail(htmlTemplate),
plaintext: post.plaintext
});
const data = {
subject: post.email_subject || post.title,
html,
plaintext
};
if (labs.isSet('newsletterPaywall')) {
data.post = post;
}
return data;
},
/**
* renderPaywallCTA
*
* outputs html for rendering paywall CTA in newsletter
*
* @param {Object} post Post Object
*/
renderPaywallCTA(post) {
const accentColor = settingsCache.get('accent_color');
const siteTitle = settingsCache.get('title') || 'Ghost';
const signupUrl = this.createPostSignupUrl(post);
return `<div class="align-center" style="text-align: center;">
<hr
style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
<h2
style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
<p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to all
<span style="white-space: nowrap;">subscriber-only content.</span></p>
<div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
<table border="0" cellspacing="0" cellpadding="0" align="center"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td align="center"
style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
valign="top" bgcolor="${accentColor}">
<a href="${signupUrl}"
style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
target="_blank">Subscribe
</a>
</td>
</tr>
</tbody>
</table>
</div>
<p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
</div>`;
},
renderEmailForSegment(email, memberSegment) {
const cheerio = require('cheerio');
const result = {...email};
/** Checks and hides content for newsletter behind paywall card
* based on member's status and post access
* Adds CTA in case content is hidden.
*/
if (labs.isSet('newsletterPaywall')) {
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1 && memberSegment && result.post) {
let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
const postVisiblity = result.post.visibility;
// For newsletter paywall, specific tiers visibility is considered on par to paid tiers
result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
if (!memberHasAccess) {
const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
result.html = result.html.slice(0, paywallIndex) + this.renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
result.plaintext = htmlToPlaintext.excerpt(result.html);
}
}
}
const $ = cheerio.load(result.html);
$('[data-gh-segment]').get().forEach((node) => {
if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
$(node).remove();
} else {
// Getting rid of the attribute for a cleaner html output
$(node).removeAttr('data-gh-segment');
}
});
result.html = this.formatHtmlForEmail($.html());
result.plaintext = htmlToPlaintext.email(result.html);
delete result.post;
return result;
}
};
const serialize = async (postModel, newsletter, options = {isBrowserPreview: false}) => {
const post = await serializePostModel(postModel);
const timezone = settingsCache.get('timezone');
const momentDate = post.published_at ? moment(post.published_at) : moment();
post.published_at = momentDate.tz(timezone).format('DD MMM YYYY');
if (post.authors) {
if (post.authors.length <= 2) {
post.authors = post.authors.map(author => author.name).join(' & ');
} else if (post.authors.length > 2) {
post.authors = `${post.authors[0].name} & ${post.authors.length - 1} others`;
}
}
if (post.posts_meta) {
post.email_subject = post.posts_meta.email_subject;
}
// we use post.excerpt as a hidden piece of text that is picked up by some email
// clients as a "preview" when listing emails. Our current plaintext/excerpt
// generation outputs links as "Link [https://url/]" which isn't desired in the preview
if (!post.custom_excerpt && post.excerpt) {
post.excerpt = post.excerpt.replace(/\s\[http(.*?)\]/g, '');
}
post.html = mobiledocLib.mobiledocHtmlRenderer.render(
JSON.parse(post.mobiledoc), {target: 'email', postUrl: post.url}
);
// perform any email specific adjustments to the mobiledoc->HTML render output
// body wrapper is required so we can get proper top-level selections
const cheerio = require('cheerio');
let _cheerio = cheerio.load(`<body>${post.html}</body>`);
// remove leading/trailing HRs
_cheerio(`
body > hr:first-child,
body > hr:last-child,
body > div:first-child > hr:first-child,
body > div:last-child > hr:last-child
`).remove();
post.html = _cheerio('body').html();
post.plaintext = htmlToPlaintext.email(post.html);
// Outlook will render feature images at full-size breaking the layout.
// Content images fix this by rendering max 600px images - do the same for feature image here
if (post.feature_image) {
if (isUnsplashImage(post.feature_image)) {
// Unsplash images have a minimum size so assuming 1200px is safe
const unsplashUrl = new URL(post.feature_image);
unsplashUrl.searchParams.set('w', '1200');
post.feature_image = unsplashUrl.href;
post.feature_image_width = 600;
} else {
const {imageSize} = require('../../lib/image');
try {
const size = await imageSize.getImageSizeFromUrl(post.feature_image);
if (size.width >= 600) {
// keep original image, just set a fixed width
post.feature_image_width = 600;
}
if (isLocalContentImage(post.feature_image, urlUtils.getSiteUrl())) {
// we can safely request a 1200px image - Ghost will serve the original if it's smaller
post.feature_image = post.feature_image.replace(/\/content\/images\//, '/content/images/size/w1200/');
}
} catch (err) {
// log and proceed. Using original feature_image without fixed width isn't fatal.
logging.error(err);
}
}
}
const templateSettings = await getTemplateSettings(newsletter);
const render = template;
let htmlTemplate = render({post, site: getSite(), templateSettings, newsletter: newsletter.toJSON()});
if (options.isBrowserPreview) {
const previewUnsubscribeUrl = createUnsubscribeUrl(null);
htmlTemplate = htmlTemplate.replace('%recipient.unsubscribe_url%', previewUnsubscribeUrl);
}
// Clean up any unknown replacements strings to get our final content
const {html, plaintext} = normalizeReplacementStrings({
html: formatHtmlForEmail(htmlTemplate),
plaintext: post.plaintext
});
const data = {
subject: post.email_subject || post.title,
html,
plaintext
};
if (labs.isSet('newsletterPaywall')) {
data.post = post;
}
return data;
};
/**
* renderPaywallCTA
*
* outputs html for rendering paywall CTA in newsletter
*
* @param {Object} post Post Object
*/
function renderPaywallCTA(post) {
const accentColor = settingsCache.get('accent_color');
const siteTitle = settingsCache.get('title') || 'Ghost';
const signupUrl = createPostSignupUrl(post);
return `<div class="align-center" style="text-align: center;">
<hr
style="position: relative; display: block; width: 100%; margin: 3em 0; padding: 0; height: 1px; border: 0; border-top: 1px solid #e5eff5;">
<h2
style="margin-top: 0; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; line-height: 1.11em; font-weight: 700; text-rendering: optimizeLegibility; margin: 1.5em 0 0.5em 0; font-size: 26px;">
Subscribe to <span style="white-space: nowrap; font-size: 26px !important;">continue reading.</span></h2>
<p style="margin: 0 auto 1.5em auto; line-height: 1.6em; max-width: 440px;">Become a paid member of ${siteTitle} to get access to all
<span style="white-space: nowrap;">subscriber-only content.</span></p>
<div class="btn btn-accent" style="box-sizing: border-box; width: 100%; display: table;">
<table border="0" cellspacing="0" cellpadding="0" align="center"
style="border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: auto;">
<tbody>
<tr>
<td align="center"
style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol'; vertical-align: top; text-align: center; border-radius: 5px;"
valign="top" bgcolor="${accentColor}">
<a href="${signupUrl}"
style="overflow-wrap: anywhere; border: solid 1px #3498db; border-radius: 5px; box-sizing: border-box; cursor: pointer; display: inline-block; font-size: 14px; font-weight: bold; margin: 0; padding: 12px 25px; text-decoration: none; background-color: ${accentColor}; border-color: ${accentColor}; color: #FFFFFF;"
target="_blank">Subscribe
</a>
</td>
</tr>
</tbody>
</table>
</div>
<p style="margin: 0 0 1.5em 0; line-height: 1.6em;"></p>
</div>`;
}
function renderEmailForSegment(email, memberSegment) {
const cheerio = require('cheerio');
const result = {...email};
/** Checks and hides content for newsletter behind paywall card
* based on member's status and post access
* Adds CTA in case content is hidden.
*/
if (labs.isSet('newsletterPaywall')) {
const paywallIndex = (result.html || '').indexOf('<!--members-only-->');
if (paywallIndex !== -1 && memberSegment && result.post) {
let statusFilter = memberSegment === 'status:free' ? {status: 'free'} : {status: 'paid'};
const postVisiblity = result.post.visibility;
// For newsletter paywall, specific tiers visibility is considered on par to paid tiers
result.post.visibility = postVisiblity === 'tiers' ? 'paid' : postVisiblity;
const memberHasAccess = membersService.contentGating.checkPostAccess(result.post, statusFilter);
if (!memberHasAccess) {
const postContentEndIdx = result.html.search(/[\s\n\r]+?<!-- POST CONTENT END -->/);
result.html = result.html.slice(0, paywallIndex) + renderPaywallCTA(result.post) + result.html.slice(postContentEndIdx);
result.plaintext = htmlToPlaintext.excerpt(result.html);
}
}
}
const $ = cheerio.load(result.html);
$('[data-gh-segment]').get().forEach((node) => {
if (node.attribs['data-gh-segment'] !== memberSegment) { //TODO: replace with NQL interpretation
$(node).remove();
} else {
// Getting rid of the attribute for a cleaner html output
$(node).removeAttr('data-gh-segment');
}
});
result.html = formatHtmlForEmail($.html());
result.plaintext = htmlToPlaintext.email(result.html);
delete result.post;
return result;
}
module.exports = {
serialize,
createUnsubscribeUrl,
createPostSignupUrl,
renderEmailForSegment,
parseReplacements,
serialize: PostEmailSerializer.serialize.bind(PostEmailSerializer),
createUnsubscribeUrl: PostEmailSerializer.createUnsubscribeUrl.bind(PostEmailSerializer),
createPostSignupUrl: PostEmailSerializer.createPostSignupUrl.bind(PostEmailSerializer),
renderEmailForSegment: PostEmailSerializer.renderEmailForSegment.bind(PostEmailSerializer),
parseReplacements: PostEmailSerializer.parseReplacements.bind(PostEmailSerializer),
// Export for tests
_getTemplateSettings: getTemplateSettings
_getTemplateSettings: PostEmailSerializer.getTemplateSettings.bind(PostEmailSerializer),
_PostEmailSerializer: PostEmailSerializer
};

View file

@ -1,6 +1,7 @@
const {escapeHtml: escape} = require('@tryghost/string');
/* eslint indent: warn, no-irregular-whitespace: warn */
const iff = (cond, yes, no) => (cond ? yes : no);
const sanitizeHtml = require('sanitize-html');
/**
* @template {Object.<string, any>} Input
@ -15,10 +16,7 @@ const sanitizeKeys = (obj, keys) => {
for (const key of keysToSanitize) {
if (typeof sanitized[key] === 'string') {
// @ts-ignore
sanitized[key] = sanitizeHtml(sanitized[key], {
allowedTags: false,
allowedAttributes: false
});
sanitized[key] = escape(sanitized[key]);
}
}
@ -28,7 +26,7 @@ const sanitizeKeys = (obj, keys) => {
module.exports = ({post, site, newsletter, templateSettings}) => {
const date = new Date();
const hasFeatureImageCaption = templateSettings.showFeatureImage && post.feature_image && post.feature_image_caption;
const cleanPost = sanitizeKeys(post, ['title', 'excerpt', 'authors', 'feature_image_alt', 'feature_image_caption']);
const cleanPost = sanitizeKeys(post, ['url', 'published_at', 'title', 'excerpt', 'authors', 'feature_image', 'feature_image_alt', 'feature_image_caption']);
const cleanSite = sanitizeKeys(site, ['title']);
const cleanNewsletter = sanitizeKeys(newsletter, ['name']);
@ -1181,7 +1179,6 @@ ${ templateSettings.showBadge ? `
<td>&nbsp;</td>
<td class="container">
<div class="content">
<!-- START CENTERED WHITE CONTAINER -->
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="main" width="100%">
@ -1189,7 +1186,6 @@ ${ templateSettings.showBadge ? `
<tr>
<td class="wrapper">
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
${ templateSettings.headerImage ? `
<tr>
<td class="header-image" width="100%" align="center"><img src="${templateSettings.headerImage}"${templateSettings.headerImageWidth ? ` width="${templateSettings.headerImageWidth}"` : ''}></td>

View file

@ -112,7 +112,7 @@
"@tryghost/social-urls": "0.1.33",
"@tryghost/staff-service": "0.0.0",
"@tryghost/stats-service": "0.3.0",
"@tryghost/string": "0.1.27",
"@tryghost/string": "0.2.0",
"@tryghost/tpl": "0.1.18",
"@tryghost/update-check-service": "0.0.0",
"@tryghost/url-utils": "4.0.3",
@ -178,7 +178,7 @@
"xml": "1.0.1"
},
"optionalDependencies": {
"@tryghost/html-to-mobiledoc": "1.8.11",
"@tryghost/html-to-mobiledoc": "1.8.12",
"sqlite3": "5.0.11"
},
"devDependencies": {
@ -193,6 +193,7 @@
"grunt": "1.5.3",
"grunt-contrib-symlink": "1.0.0",
"grunt-shell": "4.0.0",
"html-validate": "^7.3.3",
"inquirer": "8.2.4",
"jwks-rsa": "2.1.4",
"mocha": "10.0.0",

View file

@ -350,7 +350,6 @@ table.body figcaption a {
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">&#xA0;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
<!-- START CENTERED WHITE CONTAINER -->
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
@ -358,7 +357,6 @@ table.body figcaption a {
<tr>
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
@ -470,7 +468,7 @@ exports[`Email Preview API Read can read post email preview with email card and
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": "18192",
"content-length": "18188",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -738,7 +736,6 @@ table.body figcaption a {
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">&#xA0;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
<!-- START CENTERED WHITE CONTAINER -->
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
@ -746,7 +743,6 @@ table.body figcaption a {
<tr>
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
@ -874,7 +870,7 @@ exports[`Email Preview API Read can read post email preview with fields 2: [head
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": "23017",
"content-length": "23013",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -1172,7 +1168,6 @@ table.body figcaption a {
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">&#xA0;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
<!-- START CENTERED WHITE CONTAINER -->
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
@ -1180,7 +1175,6 @@ table.body figcaption a {
<tr>
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
@ -1286,7 +1280,7 @@ exports[`Email Preview API Read has custom content transformations for email com
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": "17954",
"content-length": "17950",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -1554,7 +1548,6 @@ table.body figcaption a {
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">&#xA0;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
<!-- START CENTERED WHITE CONTAINER -->
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
@ -1562,7 +1555,6 @@ table.body figcaption a {
<tr>
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
<tr>
<td class=\\"header-image\\" width=\\"100%\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; padding-top: 16px;\\" valign=\\"top\\"><img src=\\"http://127.0.0.1:2369/content/images/2022/05/test.jpg\\" style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%;\\"></td>
@ -1672,7 +1664,7 @@ exports[`Email Preview API Read uses the newsletter provided through ?newsletter
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": "18320",
"content-length": "18316",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",
@ -2326,7 +2318,6 @@ table.body figcaption a {
<td style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A;\\" valign=\\"top\\">&#xA0;</td>
<td class=\\"container\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; display: block; max-width: 600px; margin: 0 auto;\\" valign=\\"top\\">
<div class=\\"content\\" style=\\"box-sizing: border-box; display: block; margin: 0 auto; max-width: 600px;\\">
<!-- START CENTERED WHITE CONTAINER -->
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" class=\\"main\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; background: #ffffff; border-radius: 3px; width: 100%;\\">
@ -2334,7 +2325,6 @@ table.body figcaption a {
<tr>
<td class=\\"wrapper\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; box-sizing: border-box; padding: 0 20px;\\" valign=\\"top\\">
<table role=\\"presentation\\" border=\\"0\\" cellpadding=\\"0\\" cellspacing=\\"0\\" width=\\"100%\\" style=\\"border-collapse: separate; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%;\\">
<tr>
<td class=\\"header-image\\" width=\\"100%\\" align=\\"center\\" style=\\"font-family: -apple-system, BlinkMacSystemFont, &#39;Segoe UI&#39;, Roboto, Helvetica, Arial, sans-serif, &#39;Apple Color Emoji&#39;, &#39;Segoe UI Emoji&#39;, &#39;Segoe UI Symbol&#39;; font-size: 18px; vertical-align: top; color: #15212A; padding-top: 16px;\\" valign=\\"top\\"><img src=\\"http://127.0.0.1:2369/content/images/2022/05/test.jpg\\" style=\\"border: none; -ms-interpolation-mode: bicubic; max-width: 100%;\\"></td>
@ -2444,7 +2434,7 @@ exports[`Email Preview API Read uses the posts newsletter by default 2: [headers
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": "18320",
"content-length": "18316",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Origin, Accept-Encoding",

View file

@ -1,12 +1,15 @@
const should = require('should');
const assert = require('assert');
const sinon = require('sinon');
const settingsCache = require('../../../../../core/shared/settings-cache');
const models = require('../../../../../core/server/models');
const urlUtils = require('../../../../../core/shared/url-utils');
const urlService = require('../../../../../core/server/services/url');
const labs = require('../../../../../core/shared/labs');
const {parseReplacements, renderEmailForSegment, serialize, _getTemplateSettings, createUnsubscribeUrl, createPostSignupUrl, _PostEmailSerializer} = require('../../../../../core/server/services/mega/post-email-serializer');
const {parseReplacements, renderEmailForSegment, _getTemplateSettings, createUnsubscribeUrl, createPostSignupUrl} = require('../../../../../core/server/services/mega/post-email-serializer');
function assertKeys(object, keys) {
assert.deepStrictEqual(Object.keys(object).sort(), keys.sort());
}
describe('Post Email Serializer', function () {
it('creates replacement pattern for valid format and value', function () {
@ -18,12 +21,12 @@ describe('Post Email Serializer', function () {
plaintext
});
replaced.length.should.equal(2);
replaced[0].format.should.equal('html');
replaced[0].recipientProperty.should.equal('member_first_name');
assert.equal(replaced.length, 2);
assert.equal(replaced[0].format, 'html');
assert.equal(replaced[0].recipientProperty, 'member_first_name');
replaced[1].format.should.equal('plaintext');
replaced[1].recipientProperty.should.equal('member_first_name');
assert.equal(replaced[1].format, 'plaintext');
assert.equal(replaced[1].recipientProperty, 'member_first_name');
});
it('does not create replacements for unsupported variable names', function () {
@ -35,7 +38,129 @@ describe('Post Email Serializer', function () {
plaintext
});
replaced.length.should.equal(0);
assert.equal(replaced.length, 0);
});
describe('serialize', function () {
it('should output valid HTML and escape HTML characters in mobiledoc', async function () {
sinon.stub(_PostEmailSerializer, 'serializePostModel').callsFake(async () => {
return {
// This is not realistic, but just to test escaping
url: 'https://testpost.com/t&es<3t-post"</body>/',
title: 'This is\' a blog po"st test <3</body>',
excerpt: 'This is a blog post test <3</body>',
authors: 'This is a blog post test <3</body>',
feature_image_alt: 'This is a blog post test <3</body>',
feature_image_caption: 'This is a blog post test <3</body>',
// This is a markdown post with all cards that contain <3 in all fields + </body> tags
// Note that some fields are already escaped in the frontend
// eslint-disable-next-line
mobiledoc: JSON.stringify({"version":"0.3.1","atoms":[],"cards":[['markdown',{markdown: 'This is a test markdown <3'}],['email',{html: '<p>Hey {first_name, "there"}, &lt;3</p>'}],['button',{alignment: 'center',buttonText: 'Button <3 </body>',buttonUrl: 'I <3 test </body>'}],['embed',{url: 'https://opensea.io/assets/0x495f947276749ce646f68ac8c248420045cb7b5e/85405838485527185183935784047901288096962687962314908211909792283039451054081/',type: 'nft',metadata: {version: '1.0',title: '<3 LOVE PENGUIN #1',author_name: 'Yeex',author_url: 'https://opensea.io/Yeex',provider_name: 'OpenSea',provider_url: 'https://opensea.io',image_url: 'https://lh3.googleusercontent.com/d1N3L-OGHpCptdTHMJxqBJtIfZFAJ-CSv0ZDwsaQTtPqy7NHCt_GVmnQoWt0S8Pfug4EmQr4UdPjrYSjop1KTKJfLt6DWmjnXdLdrQ',creator_name: 'Yeex<3',description: '<3 LOVE PENGUIN #1',collection_name: '<3 LOVE PENGUIN'},caption: 'I &lt;3 NFT captions'}],['callout',{calloutEmoji: '💡',calloutText: 'Callout test &lt;3',backgroundColor: 'grey'}],['toggle',{heading: 'Toggle &lt;3 header',content: '<p>Toggle &lt;3 content</p>'}],['video',{loop: false,src: '__GHOST_URL__/content/media/2022/09/20220"829-<3ghost</body>.mp4',fileName: '20220829 ghos"t.mp4',width: 3072,height: 1920,duration: 221.5,mimeType: 'video/mp4',thumbnailSrc: '__GHOST_URL__/content/images/2022/09/media-th\'umbn"ail-<3</body>.jpg',thumbnailWidth: 3072,thumbnailHeight: 1920,caption: 'Test &lt;3'}],['file',{loop: false,src: '__GHOST_URL__/content/files/2022/09/image<3</body>.png',fileName: 'image<3</body>.png',fileTitle: 'Image 1<3</body>',fileCaption: '<3</body>',fileSize: 152594}],['audio',{loop: false,src: '__GHOST_URL__/content/media/2022/09/sound<3</body>.mp3',title: 'I <3</body> audio files',duration: 27.252,mimeType: 'audio/mpeg'}],['file',{loop: false,src: '__GHOST_URL__/content/files/2022/09/image<3</body>.png',fileName: 'image<3</body>.png',fileTitle: 'I <3</body> file names',fileCaption: 'I <3</body> file descriptions',fileSize: 152594}],['embed',{caption: 'I &lt;3 YouTube videos Lost On You',html: '<iframe width="200" height="113" src="https://www.youtube.com/embed/wDjeBNv6ip0?feature=oembed" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen title="LP - Lost On You (Live)"></iframe>',metadata: {author_name: 'LP',author_url: 'https://www.youtube.com/c/LP',height: 113,provider_name: 'YouTube',provider_url: 'https://www.youtube.com/',thumbnail_height: 360,thumbnail_url: 'https://i.ytimg.com/vi/wDjeBNv6ip0/hqdefault.jpg',thumbnail_width: 480,title: 'LP - Lost On You <3 (Live)',version: '1.0',width: 200},type: 'video',url: 'https://www.youtube.com/watch?v=wDjeBNv6ip0&list=RDwDjeBNv6ip0&start_radio=1'}],['image',{src: '__GHOST_URL__/content/images/2022/09/"<3</body>.png',width: 780,height: 744,caption: 'i &lt;3 images',alt: 'I <3</body> image alts'}],['gallery',{images: [{fileName: 'image<3</body>.png',row: 0,width: 780,height: 744,src: '__GHOST_URL__/content/images/2022/09/<3</body>.png'}],caption: 'I &lt;3 image galleries'}],['hr',{}]],markups: [['a',['href','https://google.com/<3</body>']],['strong'],['em']],sections: [[1,'p',[[0,[],0,'This is a <3</body> post test']]],[10,0],[10,1],[10,2],[10,3],[10,4],[10,5],[10,6],[10,7],[10,8],[10,9],[10,10],[10,11],[10,12],[1,'p',[[0,[0],1,'https://google.com/<3</body>']]],[1,'p',[[0,[],0,'Paragraph test <3</body>']]],[1,'p',[[0,[1],1,'Bold paragraph test <3</body>']]],[1,'h3',[[0,[],0,'Heading test <3</body>']]],[1,'blockquote',[[0,[],0,'Quote test <3</body>']]],[1,'p',[[0,[2],1,'Italic test<3</body>']]],[1,'p',[]]],ghostVersion: '4.0'})
};
});
const customSettings = {
icon: 'icon2<3</body>',
accent_color: '#000099',
timezone: 'UTC'
};
const settingsMock = sinon.stub(settingsCache, 'get');
settingsMock.callsFake(function (key, options) {
if (customSettings[key]) {
return customSettings[key];
}
return settingsMock.wrappedMethod.call(settingsCache, key, options);
});
const template = {
name: 'My newsletter <3</body>',
header_image: 'https://testpost.com/test-post</body>/',
show_header_icon: true,
show_header_title: true,
show_feature_image: true,
title_font_category: 'sans-serif',
title_alignment: 'center',
body_font_category: 'serif',
show_badge: true,
show_header_name: true,
// Note: we don't need to check the footer content because this should contain valid HTML (not text)
footer_content: '<span>Footer content with valid HTML</span>'
};
const newsletterMock = {
get: function (key) {
return template[key];
},
toJSON: function () {
return template;
}
};
const output = await serialize({}, newsletterMock, {isBrowserPreview: false});
// Test if the email HTML is valid standard HTML5
const {HtmlValidate} = require('html-validate');
const htmlvalidate = new HtmlValidate({
extends: [
'html-validate:document',
'html-validate:standard'
],
rules: {
// We need deprecated attrs for legacy tables in older email clients
'no-deprecated-attr': 'off',
// Don't care that the first <hx> isn't <h1>
'heading-level': 'off'
},
elements: [
'html5',
// By default, html-validate requires the 'lang' attribute on the <html> tag. We don't really want that for now.
{
html: {
attributes: {
lang: {
required: false
}
}
}
}
]
});
const report = htmlvalidate.validateString(output.html);
// Improve debugging and show a snippet of the invalid HTML instead of just the line number or a huge HTML-dump
const parsedErrors = [];
if (!report.valid) {
const lines = output.html.split('\n');
const messages = report.results[0].messages;
for (const item of messages) {
if (item.severity !== 2) {
// Ignore warnings
continue;
}
const start = Math.max(item.line - 4, 0);
const end = Math.min(item.line + 4, lines.length - 1);
const html = lines.slice(start, end).map(l => l.trim()).join('\n');
parsedErrors.push(`${item.ruleId}: ${item.message}\n At line ${item.line}, col ${item.column}\n HTML-snippet:\n${html}`);
}
}
// Fail if invalid HTML
assert.equal(report.valid, true, 'Expected valid HTML without warnings, got errors:\n' + parsedErrors.join('\n\n'));
// Check footer content is not escaped
assert.equal(output.html.includes(template.footer_content), true);
// Check doesn't contain the non escaped string '<3'
assert.equal(output.html.includes('<3'), false);
// Check if the template is rendered fully to the end (to make sure we acutally test all these mobiledocs)
assert.equal(output.html.includes('Heading test &lt;3'), true);
});
});
describe('renderEmailForSegment', function () {
@ -52,10 +177,10 @@ describe('Post Email Serializer', function () {
let output = renderEmailForSegment(email, 'status:free');
output.should.have.keys('html', 'plaintext', 'otherProperty');
output.html.should.eql('<div>test</div>');
output.plaintext.should.eql('test');
output.otherProperty.should.eql(true); // Make sure to keep other properties
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
assert.equal(output.html, '<div>test</div>');
assert.equal(output.plaintext, 'test');
assert.equal(output.otherProperty, true); // Make sure to keep other properties
});
it('should hide non matching member segments', function () {
@ -68,15 +193,15 @@ describe('Post Email Serializer', function () {
let output = renderEmailForSegment(email, 'status:free');
output.should.have.keys('html', 'plaintext', 'otherProperty');
output.html.should.eql('hello<div> free users!</div>');
output.plaintext.should.eql('hello free users!');
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
assert.equal(output.html, 'hello<div> free users!</div>');
assert.equal(output.plaintext, 'hello free users!');
output = renderEmailForSegment(email, 'status:-free');
output.should.have.keys('html', 'plaintext', 'otherProperty');
output.html.should.eql('hello<div> paid users!</div>');
output.plaintext.should.eql('hello paid users!');
assertKeys(output, ['html', 'plaintext', 'otherProperty']);
assert.equal(output.html, 'hello<div> paid users!</div>');
assert.equal(output.plaintext, 'hello paid users!');
});
it('should hide all segments when the segment filter is empty', function () {
@ -87,8 +212,8 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, null);
output.html.should.equal('hello');
output.plaintext.should.equal('hello');
assert.equal(output.html, 'hello');
assert.equal(output.plaintext, 'hello');
});
it('should show paywall content for free members on paid posts', function () {
@ -104,15 +229,16 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, 'status:free');
output.html.should.containEql(`<p>Free content</p>`);
output.html.should.containEql(`Subscribe to`);
output.html.should.containEql(`https://site.com/blah/#/portal/signup`);
output.html.should.not.containEql(`<p>Members content</p>`);
assert(output.html.includes());
assert(output.html.includes(`<p>Free content</p>`));
assert(output.html.includes(`Subscribe to`));
assert(output.html.includes(`https://site.com/blah/#/portal/signup`));
assert(!output.html.includes(`<p>Members content</p>`));
output.plaintext.should.containEql(`Free content`);
output.plaintext.should.containEql(`Subscribe to`);
output.plaintext.should.containEql(`https://site.com/blah/#/portal/signup`);
output.plaintext.should.not.containEql(`Members content`);
assert(output.plaintext.includes(`Free content`));
assert(output.plaintext.includes(`Subscribe to`));
assert(output.plaintext.includes(`https://site.com/blah/#/portal/signup`));
assert(!output.plaintext.includes(`Members content`));
});
it('should show full cta for paid members on paid posts', function () {
@ -128,8 +254,8 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, 'status:-free');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`Free content\n\nMembers content`);
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should show paywall content for free members on specific tier posts', function () {
@ -145,15 +271,15 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, 'status:free');
output.html.should.containEql(`<p>Free content</p>`);
output.html.should.containEql(`Subscribe to`);
output.html.should.containEql(`https://site.com/blah/#/portal/signup`);
output.html.should.not.containEql(`<p>Members content</p>`);
assert(output.html.includes(`<p>Free content</p>`));
assert(output.html.includes(`Subscribe to`));
assert(output.html.includes(`https://site.com/blah/#/portal/signup`));
assert(!output.html.includes(`<p>Members content</p>`));
output.plaintext.should.containEql(`Free content`);
output.plaintext.should.containEql(`Subscribe to`);
output.plaintext.should.containEql(`https://site.com/blah/#/portal/signup`);
output.plaintext.should.not.containEql(`Members content`);
assert(output.plaintext.includes(`Free content`));
assert(output.plaintext.includes(`Subscribe to`));
assert(output.plaintext.includes(`https://site.com/blah/#/portal/signup`));
assert(!output.plaintext.includes(`Members content`));
});
it('should show full cta for paid members on specific tier posts', function () {
@ -169,8 +295,8 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, 'status:-free');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`Free content\n\nMembers content`);
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should show full content for free members on free posts', function () {
@ -186,8 +312,8 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, 'status:free');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`Free content\n\nMembers content`);
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should show full content for paid members on free posts', function () {
@ -203,8 +329,8 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, 'status:-free');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`Free content\n\nMembers content`);
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
it('should not crash on missing post for email with paywall', function () {
@ -216,8 +342,8 @@ describe('Post Email Serializer', function () {
};
let output = renderEmailForSegment(email, 'status:-free');
output.html.should.equal(`<p>Free content</p><!--members-only--><p>Members content</p>`);
output.plaintext.should.equal(`Free content\n\nMembers content`);
assert.equal(output.html, `<p>Free content</p><!--members-only--><p>Members content</p>`);
assert.equal(output.plaintext, `Free content\n\nMembers content`);
});
});
@ -233,25 +359,25 @@ describe('Post Email Serializer', function () {
it('generates unsubscribe url for preview', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl(null);
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?preview=1');
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?preview=1');
});
it('generates unsubscribe url with only member uuid', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl('member-abcd');
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?uuid=member-abcd');
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd');
});
it('generates unsubscribe url with both post and newsletter uuid', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl('member-abcd', {newsletterUuid: 'newsletter-abcd'});
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?uuid=member-abcd&newsletter=newsletter-abcd');
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd&newsletter=newsletter-abcd');
});
it('generates unsubscribe url with comments', function () {
sinon.stub(urlUtils, 'getSiteUrl').returns('https://site.com/blah');
const unsubscribeUrl = createUnsubscribeUrl('member-abcd', {comments: true});
unsubscribeUrl.should.eql('https://site.com/blah/unsubscribe/?uuid=member-abcd&comments=1');
assert.equal(unsubscribeUrl, 'https://site.com/blah/unsubscribe/?uuid=member-abcd&comments=1');
});
});
@ -270,7 +396,7 @@ describe('Post Email Serializer', function () {
status: 'published',
id: 'abc123'
});
unsubscribeUrl.should.eql('https://site.com/blah/#/portal/signup');
assert.equal(unsubscribeUrl, 'https://site.com/blah/#/portal/signup');
});
it('generates signup url on homepage for email only post', function () {
@ -280,7 +406,7 @@ describe('Post Email Serializer', function () {
status: 'sent',
id: 'abc123'
});
unsubscribeUrl.should.eql('https://site.com/test/#/portal/signup');
assert.equal(unsubscribeUrl, 'https://site.com/test/#/portal/signup');
});
});
@ -317,7 +443,7 @@ describe('Post Email Serializer', function () {
}
};
const res = await _getTemplateSettings(newsletterMock);
should(res).eql({
assert.deepStrictEqual(res, {
headerImage: 'image',
showHeaderIcon: 'icon2',
showHeaderTitle: true,

View file

@ -120,8 +120,6 @@ describe('Mega template', function () {
});
it('Correctly escapes the contents', function () {
// TODO: check html escaping based on mobiledoc instead of invalid html: https://github.com/TryGhost/Team/issues/1871
const post = {
title: 'I <3 Posts',
html: '<div class="post-content-html">I am &lt;100 years old</div>',

View file

@ -26,7 +26,7 @@
"@tryghost/domain-events": "0.0.0",
"@tryghost/errors": "1.2.15",
"@tryghost/mongo-utils": "0.3.5",
"@tryghost/string": "0.1.27",
"@tryghost/string": "0.2.0",
"lodash": "4.17.21"
}
}

View file

@ -23,7 +23,7 @@
"uuid": "9.0.0"
},
"dependencies": {
"@tryghost/string": "0.1.27",
"@tryghost/string": "0.2.0",
"bcryptjs": "2.4.3"
}
}

116
yarn.lock
View file

@ -659,7 +659,7 @@
dependencies:
"@babel/highlight" "^7.10.4"
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.18.6":
"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6":
version "7.18.6"
resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a"
integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q==
@ -2535,6 +2535,13 @@
ember-compatibility-helpers "^1.2.1"
ember-raf-scheduler "^0.3.0"
"@html-validate/stylish@^3.0.0":
version "3.0.1"
resolved "https://registry.yarnpkg.com/@html-validate/stylish/-/stylish-3.0.1.tgz#9d0304c6fd91e4b19467b8b7d6f3f112fc7831ff"
integrity sha512-jQNDrSnWvJEPSlqC1tFqcbmVuJy2x61UwqFsXHxYT2sgCXFW4AVhsoIcHkECCmUHtQ8hpHU6yOBGA+rMLZhS7A==
dependencies:
kleur "^4.0.0"
"@humanwhocodes/config-array@^0.10.4":
version "0.10.4"
resolved "https://registry.yarnpkg.com/@humanwhocodes/config-array/-/config-array-0.10.4.tgz#01e7366e57d2ad104feea63e72248f22015c520c"
@ -3044,6 +3051,14 @@
"@sentry/types" "7.8.1"
tslib "^1.9.3"
"@sidvind/better-ajv-errors@^2.0.0":
version "2.0.0"
resolved "https://registry.yarnpkg.com/@sidvind/better-ajv-errors/-/better-ajv-errors-2.0.0.tgz#88f822f04ea7314fc4e7fcc5e4b393640c74f6ab"
integrity sha512-S+3eyeMbWQifgbDF15eojGVJi8n5uU0bGJYQsvsHPyU4lmrMsXvfiCh7MM0IPEF4a4jRBsljMExtKlo7kM0jqQ==
dependencies:
"@babel/code-frame" "^7.16.0"
chalk "^4.1.0"
"@simple-dom/document@^1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@simple-dom/document/-/document-1.4.0.tgz#af60855f957f284d436983798ef1006cca1a1678"
@ -3364,12 +3379,12 @@
dependencies:
lodash-es "^4.17.11"
"@tryghost/html-to-mobiledoc@1.8.11":
version "1.8.11"
resolved "https://registry.yarnpkg.com/@tryghost/html-to-mobiledoc/-/html-to-mobiledoc-1.8.11.tgz#e4221f7db94d5b18ac15fece907d3024e124141b"
integrity sha512-I8S8Y8rGCDpTSh0/3pPlS/8UepwEH5ORt83TZoYTx87Pt+evUPIvsq2XfppTkoclvv/vHsD5ntWD0Yt3Gwl4fg==
"@tryghost/html-to-mobiledoc@1.8.12":
version "1.8.12"
resolved "https://registry.yarnpkg.com/@tryghost/html-to-mobiledoc/-/html-to-mobiledoc-1.8.12.tgz#762d8275dec94f5213a18afd178cb76d188870a2"
integrity sha512-Zy78/VRCoT47LX7FCDM48JwrmV84uPeIUFRWw56+PDoSYoUO1QPE+38+iqPcfvs55GEQ5x+trhqKu0iYKh5MXg==
dependencies:
"@tryghost/kg-parser-plugins" "^2.12.0"
"@tryghost/kg-parser-plugins" "^2.12.1"
"@tryghost/mobiledoc-kit" "^0.12.4-ghost.1"
jsdom "^18.0.0"
@ -3407,15 +3422,10 @@
resolved "https://registry.yarnpkg.com/@tryghost/kg-card-factory/-/kg-card-factory-3.1.5.tgz#c2358e7ed90ff71900a4da72e43aaa744aefdf35"
integrity sha512-V68OpboKudFkx4ezKEqRVjZp7Q21EHC3FnpYT7RR+3deUqAm/Gu04s3mcsei0yTHPfuUEcHkjP7sEUZJyJSZeg==
"@tryghost/kg-clean-basic-html@2.2.14":
version "2.2.14"
resolved "https://registry.yarnpkg.com/@tryghost/kg-clean-basic-html/-/kg-clean-basic-html-2.2.14.tgz#d8c2750e8832e3c1d316cfe7eb09243d34b70f1f"
integrity sha512-qTbREH4aHTWpn9c7cTMCWN/exWybhkVnci62L+99oVO6CIHpLQfww+zL66YWhiqRU+TGGFQj7qHG93FquGtysg==
"@tryghost/kg-clean-basic-html@^2.2.15":
version "2.2.15"
resolved "https://registry.yarnpkg.com/@tryghost/kg-clean-basic-html/-/kg-clean-basic-html-2.2.15.tgz#25d5ded0da6c6c9f3d5fbb3bf60579f08be65783"
integrity sha512-p8pwBt6H8PWtfCQrG26atBybcCxzeMGOA/8suQDJ8ce5ieU57YwQHxLSTqa3/oGSfTLLuBBtSgD4G5NBnOOXxw==
"@tryghost/kg-clean-basic-html@2.2.16", "@tryghost/kg-clean-basic-html@^2.2.16":
version "2.2.16"
resolved "https://registry.yarnpkg.com/@tryghost/kg-clean-basic-html/-/kg-clean-basic-html-2.2.16.tgz#6610b9af1e97732f8da12de0e72ed5796f5d29f5"
integrity sha512-2gC4J6gkiLGOd7SNl1BdSf/qE0uKyNNDvOyPFCZPKruW6DJ56PmBXceBlPm5q+V1PwAmLmzAYBGO5CfosKdbHg==
"@tryghost/kg-default-atoms@3.1.4":
version "3.1.4"
@ -3457,12 +3467,12 @@
mobiledoc-dom-renderer "^0.7.0"
simple-dom "^1.4.0"
"@tryghost/kg-parser-plugins@2.12.0", "@tryghost/kg-parser-plugins@^2.12.0":
version "2.12.0"
resolved "https://registry.yarnpkg.com/@tryghost/kg-parser-plugins/-/kg-parser-plugins-2.12.0.tgz#74ad083d4fc3e1d1840848f257d01e2e25c8853c"
integrity sha512-trRn612AWLlt9qly8nxDnrM5PHuDHmhogxof/G2oQzDkXQ7IUPyLBz/7V2yUCSlQ1VQVvhxoqqTbUnPYzSUVAw==
"@tryghost/kg-parser-plugins@2.12.1", "@tryghost/kg-parser-plugins@^2.12.1":
version "2.12.1"
resolved "https://registry.yarnpkg.com/@tryghost/kg-parser-plugins/-/kg-parser-plugins-2.12.1.tgz#91fc233a72990493b9377b82561b37416314028b"
integrity sha512-mZmZraOU/uICmeeAAlWffbGtlkgtBW7fvB90rPJ+V5kv1ojRxa38cy0a/EUrjiir2dUCVk2zJFdTzHuf4UevEg==
dependencies:
"@tryghost/kg-clean-basic-html" "^2.2.15"
"@tryghost/kg-clean-basic-html" "^2.2.16"
"@tryghost/kg-utils@^1.0.4":
version "1.0.4"
@ -3639,14 +3649,7 @@
dependencies:
moment "^2.29.3"
"@tryghost/string@0.1.27":
version "0.1.27"
resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.1.27.tgz#c8f9b9748e2a7bea02953763865e0820223a2918"
integrity sha512-M3vUxiV4atX10aV45VOgs8DW9X29z5gd/KhfSP3AzKeu2rEiLx3nWC4lD7xTMZocZED1QNwvGXbjMEwM9aNGmw==
dependencies:
unidecode "^0.1.8"
"@tryghost/string@^0.2.0":
"@tryghost/string@0.2.0", "@tryghost/string@^0.2.0":
version "0.2.0"
resolved "https://registry.yarnpkg.com/@tryghost/string/-/string-0.2.0.tgz#e39ca183cf5fe4522ab3f698de7d1c0affb03082"
integrity sha512-JWWXoUWaJPI/fY7+WHrfInt2S0IV1ZN+km98AIw4GwuWoXIMbUoKp1K/JKoVQ4XrJXwvNMnmxNKFChF1kpdcGQ==
@ -4446,6 +4449,11 @@ acorn-walk@^7.1.1:
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-7.2.0.tgz#0de889a601203909b0fbe07b8938dc21d2e967bc"
integrity sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==
acorn-walk@^8.0.0:
version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
acorn@^5.0.0, acorn@^5.5.3:
version "5.7.4"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-5.7.4.tgz#3e8d8a9947d0599a1796d10225d7432f4a4acf5e"
@ -8704,7 +8712,7 @@ deep-is@^0.1.3, deep-is@~0.1.3:
resolved "https://registry.yarnpkg.com/deep-is/-/deep-is-0.1.4.tgz#a6f2dce612fadd2ef1f519b73551f17e85199831"
integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==
deepmerge@^4.0.0, deepmerge@^4.2.2:
deepmerge@^4.0.0, deepmerge@^4.2.0, deepmerge@^4.2.2:
version "4.2.2"
resolved "https://registry.yarnpkg.com/deepmerge/-/deepmerge-4.2.2.tgz#44d2ea3679b8f4d4ffba33f03d865fc1e7bf4955"
integrity sha512-FJ3UgI4gIl+PHZm53knsuSFpE+nESMr7M4v9QcgB7S63Kj/6WqMiFQJpBBYz1Pt+66bZpP3Q7Lye0Oo9MPKEdg==
@ -11164,7 +11172,7 @@ espree@^7.3.0, espree@^7.3.1:
acorn-jsx "^5.3.1"
eslint-visitor-keys "^1.3.0"
espree@^9.4.0:
espree@^9.0.0, espree@^9.4.0:
version "9.4.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-9.4.0.tgz#cd4bc3d6e9336c433265fc0aa016fc1aaf182f8a"
integrity sha512-DQmnRpLj7f6TgN/NYb0MTzJXL+vJF9h3pHy4JhCIs3zwcgez8xmGg3sXHcEO97BrmO2OSvCwMdfdlyl+E9KjOw==
@ -12496,7 +12504,7 @@ glob@7.2.0:
once "^1.3.0"
path-is-absolute "^1.0.0"
glob@8.0.3, glob@^8.0.1, glob@^8.0.3:
glob@8.0.3, glob@^8.0.0, glob@^8.0.1, glob@^8.0.3:
version "8.0.3"
resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e"
integrity sha512-ull455NHSHI/Y1FqGaaYFaLGkNMMJbavMrEGFXG/PGrg6y7sutWHUHrz6gy6WEBH6akM1M414dWKCNs+IhKdiQ==
@ -13112,6 +13120,25 @@ html-to-text@8.2.1:
minimist "^1.2.6"
selderee "^0.6.0"
html-validate@^7.3.3:
version "7.3.3"
resolved "https://registry.yarnpkg.com/html-validate/-/html-validate-7.3.3.tgz#9186aad1bbf894b6ec506a1b5be33a45bda89dc6"
integrity sha512-nABzmBHUb+TbiqLb3ggoC7uEN5mKgZSwbB/LpGyXbrI+ajVBnyYyRMscLFOZGYpw5gacBZtqkK6VzFU7jbXObg==
dependencies:
"@babel/code-frame" "^7.10.0"
"@html-validate/stylish" "^3.0.0"
"@sidvind/better-ajv-errors" "^2.0.0"
acorn-walk "^8.0.0"
ajv "^8.0.0"
deepmerge "^4.2.0"
espree "^9.0.0"
glob "^8.0.0"
ignore "^5.0.0"
kleur "^4.1.0"
minimist "^1.2.0"
prompts "^2.0.0"
semver "^7.0.0"
htmlparser2@^3.10.1, htmlparser2@^3.9.1:
version "3.10.1"
resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-3.10.1.tgz#bd679dc3f59897b6a34bb10749c855bb53a9392f"
@ -13343,7 +13370,7 @@ ignore@^4.0.6:
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"
integrity sha512-cyFDKrqc/YdcWFniJhzI42+AzS+gNwmUzOSFcRCQYwySuBBBy/KjuxWLZ/FHEH6Moq1NizMOBWyTcv8O4OZIMg==
ignore@^5.1.1, ignore@^5.2.0:
ignore@^5.0.0, ignore@^5.1.1, ignore@^5.2.0:
version "5.2.0"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.2.0.tgz#6d3bac8fa7fe0d45d9f9be7bac2fc279577e345a"
integrity sha512-CmxgYGiEPCLhfLnpPp1MoRmifwEIOgjcHXxOBjv7mY96c+eWScsOP9c112ZyLdWHi0FxHjI+4uVhKYp/gcdRmQ==
@ -14939,6 +14966,16 @@ kind-of@^6.0.0, kind-of@^6.0.2:
resolved "https://registry.yarnpkg.com/kind-of/-/kind-of-6.0.3.tgz#07c05034a6c349fa06e24fa35aa76db4580ce4dd"
integrity sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==
kleur@^3.0.3:
version "3.0.3"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-3.0.3.tgz#a79c9ecc86ee1ce3fa6206d1216c501f147fc07e"
integrity sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==
kleur@^4.0.0, kleur@^4.1.0:
version "4.1.5"
resolved "https://registry.yarnpkg.com/kleur/-/kleur-4.1.5.tgz#95106101795f7050c6c650f350c683febddb1780"
integrity sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==
knex-migrator@5.0.4:
version "5.0.4"
resolved "https://registry.yarnpkg.com/knex-migrator/-/knex-migrator-5.0.4.tgz#1e2a0530bffdb432c4d9c29a1fde9171d82a3565"
@ -19096,6 +19133,14 @@ promise.hash.helper@^1.0.7:
resolved "https://registry.yarnpkg.com/promise.hash.helper/-/promise.hash.helper-1.0.8.tgz#8c5fa0570f6f96821f52364fd72292b2c5a114f7"
integrity sha512-KYcnXctWUWyVD3W3Ye0ZDuA1N8Szrh85cVCxpG6xYrOk/0CttRtYCmU30nWsUch0NuExQQ63QXvzRE6FLimZmg==
prompts@^2.0.0:
version "2.4.2"
resolved "https://registry.yarnpkg.com/prompts/-/prompts-2.4.2.tgz#7b57e73b3a48029ad10ebd44f74b01722a4cb069"
integrity sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==
dependencies:
kleur "^3.0.3"
sisteransi "^1.0.5"
prop-types@^15.8.1:
version "15.8.1"
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
@ -20301,7 +20346,7 @@ semver@7.0.0, semver@~7.0.0:
resolved "https://registry.yarnpkg.com/semver/-/semver-7.0.0.tgz#5f3ca35761e47e05b206c6daff2cf814f0316b8e"
integrity sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==
semver@7.3.7, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
semver@7.3.7, semver@^7.0.0, semver@^7.1.3, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.7:
version "7.3.7"
resolved "https://registry.yarnpkg.com/semver/-/semver-7.3.7.tgz#12c5b649afdbf9049707796e22a4028814ce523f"
integrity sha512-QlYTucUYOews+WeEujDoEGziz4K6c47V/Bd+LjSSYcA94p+DmINdf7ncaUinThfvZyu13lN9OY1XDxt8C0Tw0g==
@ -20589,6 +20634,11 @@ sinon@^9.0.0:
nise "^4.0.4"
supports-color "^7.1.0"
sisteransi@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/sisteransi/-/sisteransi-1.0.5.tgz#134d681297756437cc05ca01370d3a7a571075ed"
integrity sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==
slash@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/slash/-/slash-1.0.0.tgz#c41f2f6c39fc16d1cd17ad4b5d896114ae470d55"