From 4534b693e494c5b8794d6de6e45b9aa17c78439a Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Thu, 8 Sep 2022 10:11:01 +0200 Subject: [PATCH] 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
tag` should actually render the same title and non-html content, being `All your need to know about the <br /> 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 --- ghost/admin/package.json | 8 +- .../services/mega/post-email-serializer.js | 805 +++++++++--------- .../core/server/services/mega/template.js | 12 +- ghost/core/package.json | 5 +- .../__snapshots__/email-previews.test.js.snap | 20 +- .../mega/post-email-serializer.test.js | 232 +++-- .../server/services/mega/template.test.js | 2 - ghost/offers/package.json | 2 +- ghost/security/package.json | 2 +- yarn.lock | 116 ++- 10 files changed, 684 insertions(+), 520 deletions(-) diff --git a/ghost/admin/package.json b/ghost/admin/package.json index be2ac04b11..4d43862e36 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -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" } -} \ No newline at end of file +} diff --git a/ghost/core/core/server/services/mega/post-email-serializer.js b/ghost/core/core/server/services/mega/post-email-serializer.js index 7b60cb6648..cf02316b30 100644 --- a/ghost/core/core/server/services/mega/post-email-serializer.js +++ b/ghost/core/core/server/services/mega/post-email-serializer.js @@ -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(/'/g, '''); + // Fix any unsupported chars in Outlook + juicedHtml = juicedHtml.replace(/'/g, '''); - 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 = /\{(?\w*?)(?:,? *(?:"|")(?.*?)(?:"|"))?\}/; - - ['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 = /\{(?\w*?)(?:,? *(?:"|")(?.*?)(?:"|"))?\}/; - - 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 = /\{(?\w*?)(?:,? *(?:"|")(?.*?)(?:"|"))?\}/; + + ['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 = /\{(?\w*?)(?:,? *(?:"|")(?.*?)(?:"|"))?\}/; + + 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(`${post.html}`); + // 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 `
+
+

+ Subscribe to continue reading.

+

Become a paid member of ${siteTitle} to get access to all + subscriber-only content.

+
+ + + + + + +
+ Subscribe + +
+
+

+
`; + }, + + 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(''); + 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]+?/); + 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(`${post.html}`); - // 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 `
-
-

- Subscribe to continue reading.

-

Become a paid member of ${siteTitle} to get access to all - subscriber-only content.

-
- - - - - - -
- Subscribe - -
-
-

-
`; -} - -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(''); - 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]+?/); - 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 }; diff --git a/ghost/core/core/server/services/mega/template.js b/ghost/core/core/server/services/mega/template.js index 8440e96206..e0c7fb8127 100644 --- a/ghost/core/core/server/services/mega/template.js +++ b/ghost/core/core/server/services/mega/template.js @@ -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.} 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 ? `  
- @@ -1189,7 +1186,6 @@ ${ templateSettings.showBadge ? `