🐛 Fixed newsletters not rendering with non-HTML safe chars (#15331)

Co-authored-by: Kevin Ansfield <kevin@lookingsideways.co.uk>
This commit is contained in:
Fabien 'egg' O'Carroll 2022-08-30 06:26:01 -04:00 committed by GitHub
parent 9355c6d8fa
commit 21e473ff78
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 124 additions and 16 deletions

View File

@ -1,15 +1,43 @@
/* 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
* @param {Input} obj
* @param {string[]} [keys]
* @returns {Input}
*/
const sanitizeKeys = (obj, keys) => {
const sanitized = Object.assign({}, obj);
const keysToSanitize = keys || Object.keys(obj);
for (const key of keysToSanitize) {
if (typeof sanitized[key] === 'string') {
// @ts-ignore
sanitized[key] = sanitizeHtml(sanitized[key], {
allowedTags: false,
allowedAttributes: false
});
}
}
return sanitized;
};
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', 'html', 'feature_image_alt', 'feature_image_caption']);
const cleanSite = sanitizeKeys(site, ['title']);
const cleanNewsletter = sanitizeKeys(newsletter, ['name']);
return `<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width" />
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>${post.title}</title>
<title>${cleanPost.title}</title>
<style>
/* -------------------------------------
GLOBAL RESETS
@ -1137,7 +1165,7 @@ ${ templateSettings.showBadge ? `
</head>
<body>
<span class="preheader">${ post.excerpt ? post.excerpt : `${post.title} ` }</span>
<span class="preheader">${ cleanPost.excerpt ? cleanPost.excerpt : `${cleanPost.title} ` }</span>
<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="body" width="100%">
<!-- Outlook doesn't respect max-width so we need an extra centered table -->
@ -1172,24 +1200,24 @@ ${ templateSettings.showBadge ? `
<tr>
<td class="${templateSettings.showHeaderTitle ? `site-info-bordered` : `site-info`}" width="100%" align="center">
<table role="presentation" border="0" cellpadding="0" cellspacing="0">
${ templateSettings.showHeaderIcon && site.iconUrl ? `
${ templateSettings.showHeaderIcon && cleanSite.iconUrl ? `
<tr>
<td class="site-icon"><a href="${site.url}"><img src="${site.iconUrl}" alt="${site.title}" border="0"></a></td>
<td class="site-icon"><a href="${cleanSite.url}"><img src="${cleanSite.iconUrl}" alt="${cleanSite.title}" border="0"></a></td>
</tr>
` : ``}
${ templateSettings.showHeaderTitle ? `
<tr>
<td class="site-url ${!templateSettings.showHeaderName ? 'site-url-bottom-padding' : ''}"><div style="width: 100% !important;"><a href="${site.url}" class="site-title">${site.title}</a></div></td>
<td class="site-url ${!templateSettings.showHeaderName ? 'site-url-bottom-padding' : ''}"><div style="width: 100% !important;"><a href="${cleanSite.url}" class="site-title">${cleanSite.title}</a></div></td>
</tr>
` : ``}
${ templateSettings.showHeaderName && templateSettings.showHeaderTitle ? `
<tr>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="${site.url}" class="site-subtitle">${newsletter.name}</a></div></td>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="${cleanSite.url}" class="site-subtitle">${cleanNewsletter.name}</a></div></td>
</tr>
` : ``}
${ templateSettings.showHeaderName && !templateSettings.showHeaderTitle ? `
<tr>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="${site.url}" class="site-title">${newsletter.name}</a></div></td>
<td class="site-url site-url-bottom-padding"><div style="width: 100% !important;"><a href="${cleanSite.url}" class="site-title">${cleanNewsletter.name}</a></div></td>
</tr>
` : ``}
@ -1201,7 +1229,7 @@ ${ templateSettings.showBadge ? `
<tr>
<td class="post-title ${templateSettings.titleFontCategory === 'serif' ? `post-title-serif` : `` } ${templateSettings.titleAlignment === 'left' ? `post-title-left` : ``}">
<a href="${post.url}" class="post-title-link ${templateSettings.titleAlignment === 'left' ? `post-title-link-left` : ``}">${post.title}</a>
<a href="${cleanPost.url}" class="post-title-link ${templateSettings.titleAlignment === 'left' ? `post-title-link-left` : ``}">${cleanPost.title}</a>
</td>
</tr>
<tr>
@ -1209,28 +1237,28 @@ ${ templateSettings.showBadge ? `
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td class="post-meta ${templateSettings.titleAlignment === 'left' ? `post-meta-left` : ``}">
By ${post.authors}
${post.published_at}
<a href="${post.url}" class="view-online-link">View online </a>
By ${cleanPost.authors}
${cleanPost.published_at}
<a href="${cleanPost.url}" class="view-online-link">View online </a>
</td>
</tr>
</table>
</td>
</tr>
${ templateSettings.showFeatureImage && post.feature_image ? `
${ templateSettings.showFeatureImage && cleanPost.feature_image ? `
<tr>
<td class="feature-image ${hasFeatureImageCaption ? 'feature-image-with-caption' : ''}"><img src="${post.feature_image}"${post.feature_image_width ? ` width="${post.feature_image_width}"` : ''}${post.feature_image_alt ? ` alt="${post.feature_image_alt}"` : ''}></td>
<td class="feature-image ${hasFeatureImageCaption ? 'feature-image-with-caption' : ''}"><img src="${cleanPost.feature_image}"${cleanPost.feature_image_width ? ` width="${cleanPost.feature_image_width}"` : ''}${cleanPost.feature_image_alt ? ` alt="${cleanPost.feature_image_alt}"` : ''}></td>
</tr>
` : ``}
${ hasFeatureImageCaption ? `
<tr>
<td class="feature-image-caption" align="center">${post.feature_image_caption}</td>
<td class="feature-image-caption" align="center">${cleanPost.feature_image_caption}</td>
</tr>
` : ``}
<tr>
<td class="${(templateSettings.bodyFontCategory === 'sans_serif') ? `post-content-sans-serif` : `post-content` }">
<!-- POST CONTENT START -->
${post.html}
${cleanPost.html}
<!-- POST CONTENT END -->
</td>
</tr>
@ -1245,7 +1273,7 @@ ${ templateSettings.showBadge ? `
<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="padding-top: 40px; padding-bottom: 30px;">
${iff(!!templateSettings.footerContent, `<tr><td class="footer">${templateSettings.footerContent}</td></tr>`, '')}
<tr>
<td class="footer">${site.title} &copy; ${date.getFullYear()} <a href="%recipient.unsubscribe_url%">Unsubscribe</a></td>
<td class="footer">${cleanSite.title} &copy; ${date.getFullYear()} <a href="%recipient.unsubscribe_url%">Unsubscribe</a></td>
</tr>
${ templateSettings.showBadge ? `

View File

@ -119,6 +119,86 @@ describe('Mega template', function () {
should(footerPowered.find('a img').attr('alt')).eql('Powered by Ghost');
});
it('Correctly escapes the contents', function () {
const post = {
title: 'I <3 Posts',
html: '<div class="post-content-html">I am <100 years old</div>',
feature_image: 'https://example.com/image.jpg',
feature_image_alt: 'I <3 alt text',
feature_image_caption: 'I <3 images'
};
const site = {
iconUrl: 'site icon url',
url: 'site url',
title: 'Egg <3 eggs'
};
const templateSettings = {
headerImage: 'header image',
headerImageWidth: '600',
showHeaderIcon: true,
showHeaderTitle: true,
showHeaderName: true,
titleAlignment: 'left',
titleFontCategory: 'serif',
showFeatureImage: true,
bodyFontCategory: 'sans_serif',
footerContent: 'footer content',
showBadge: true
};
const newsletter = {
name: '<100 eggs to go'
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should($('.site-title').text()).eql(site.title);
should($('.site-title').html()).eql('Egg &lt;3 eggs');
should($('.post-content-html').length).eql(1);
should($('.post-content-html').text()).eql('I am <100 years old');
should($('.post-content-html').html()).eql('I am &lt;100 years old');
should($('.feature-image').html()).containEql('"I &lt;3 alt text"');
should($('.feature-image-caption').html()).eql('I &lt;3 images');
should($('.site-subtitle').html()).eql('&lt;100 eggs to go');
});
it('Doesn\'t strip class or style attributes when escaping content', function () {
const post = {
title: 'I <3 Posts',
html: '<div class="post-content-html"><span class="custom" style="font-weight: 900; display: flex;">BOLD</span></div>'
};
const site = {
iconUrl: 'site icon url',
url: 'site url',
title: 'Egg <3 eggs'
};
const templateSettings = {
headerImage: 'header image',
headerImageWidth: '600',
showHeaderIcon: true,
showHeaderTitle: true,
showHeaderName: true,
titleAlignment: 'left',
titleFontCategory: 'serif',
showFeatureImage: true,
bodyFontCategory: 'sans_serif',
footerContent: 'footer content',
showBadge: true
};
const newsletter = {
name: '<100 eggs to go'
};
const html = render({post, site, templateSettings, newsletter});
const $ = cheerio.load(html);
should(html).containEql('class="custom"');
// note that some part of rendering/sanitisation removes spaces from the style description
should(html).containEql('style="font-weight:900;display:flex"');
});
it('Uses the post title as a fallback for the excerpt', function () {
const post = {
title: 'My post title'