Added empty line trimming to comment messages

fixes https://github.com/TryGhost/Team/issues/1737

- Empty lines at start
- Empty lines at end
- Duplicate empty lines inside the comment message (max one allowed)
This commit is contained in:
Simon Backx 2022-08-02 17:44:00 +02:00
parent 3f8ddd61f9
commit e112f1cd40
5 changed files with 66 additions and 28 deletions

View File

@ -9,6 +9,24 @@ const messages = {
notYourCommentToDestroy: 'You may only delete your own comments'
};
function escapeRegex(string) {
return string.replace(/[-/\\^$*+?.()|[\]{}]/g, '\\$&');
}
/**
* Remove empty paragraps from the start and end
* + remove duplicate empty paragrapsh (only one empty line allowed)
*/
function trimParagraphs(str) {
const paragraph = '<p></p>';
const escapedParagraph = escapeRegex(paragraph);
const startReg = new RegExp('^(' + escapedParagraph + ')+');
const endReg = new RegExp('(' + escapedParagraph + ')+$');
const duplicates = new RegExp('(' + escapedParagraph + ')+');
return str.replace(startReg, '').replace(endReg, '').replace(duplicates, paragraph);
}
const Comment = ghostBookshelf.Model.extend({
tableName: 'comments',
@ -49,20 +67,22 @@ const Comment = ghostBookshelf.Model.extend({
if (this.hasChanged('html')) {
const sanitizeHtml = require('sanitize-html');
this.set('html', sanitizeHtml(this.get('html'), {
allowedTags: ['p', 'br', 'a', 'blockquote'],
allowedAttributes: {
a: ['href', 'target', 'rel']
},
selfClosing: ['br'],
// Enforce _blank and safe URLs
transformTags: {
a: sanitizeHtml.simpleTransform('a', {
target: '_blank',
rel: 'ugc noopener noreferrer nofollow'
})
}
}));
this.set('html', trimParagraphs(
sanitizeHtml(this.get('html'), {
allowedTags: ['p', 'br', 'a', 'blockquote'],
allowedAttributes: {
a: ['href', 'target', 'rel']
},
selfClosing: ['br'],
// Enforce _blank and safe URLs
transformTags: {
a: sanitizeHtml.simpleTransform('a', {
target: '_blank',
rel: 'ugc noopener noreferrer nofollow'
})
}
})
));
}
},

View File

@ -129,7 +129,7 @@ class CommentsServiceEmails {
postTitle: post.get('title'),
postUrl: this.urlService.getUrlByResourceId(post.get('id'), {absolute: true}),
commentHtml: comment.get('html'),
commentText: htmlToPlaintext.email(comment.get('html')),
commentText: htmlToPlaintext.comment(comment.get('html')),
commentDate: moment(comment.get('created_at')).tz(this.settingsCache.get('timezone')).format('D MMM YYYY'),
reporterName: reporter.name,

View File

@ -34,6 +34,7 @@ const baseSettings = {
let excerptConverter;
let emailConverter;
let commentConverter;
const loadConverters = () => {
if (excerptConverter && emailConverter) {
@ -63,8 +64,19 @@ const loadConverters = () => {
]
});
const commentSettings = mergeSettings({
preserveNewlines: false,
selectors: [
// equiv hideLinkHrefIfSameAsText: true
{selector: 'a', options: {hideLinkHrefIfSameAsText: true}},
// No space between <p> tags. An empty <p> is needed
{selector: 'p', options: {leadingLineBreaks: 1, trailingLineBreaks: 1}}
]
});
excerptConverter = compile(excerptSettings);
emailConverter = compile(emailSettings);
commentConverter = compile(commentSettings);
};
module.exports.excerpt = (html) => {
@ -78,3 +90,9 @@ module.exports.email = (html) => {
return emailConverter(html);
};
module.exports.comment = (html) => {
loadConverters();
return commentConverter(html);
};

View File

@ -40,7 +40,7 @@ Object {
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a message</p><p>New line</p>",
"html": "<p>This is a message</p><p></p><p>New line</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"likes_count": Any<Number>,
@ -72,7 +72,7 @@ exports[`Comments API when authenticated Can browse all comments of a post 2: [h
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "1057",
"content-length": "1064",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -86,7 +86,7 @@ Object {
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a message</p><p>New line</p>",
"html": "<p>This is a message</p><p></p><p>New line</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": false,
"likes_count": 0,
@ -101,7 +101,7 @@ exports[`Comments API when authenticated Can comment on a post 2: [headers] 1`]
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "219",
"content-length": "226",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/comments\\\\/\\[a-f0-9\\]\\{24\\}\\\\//,
@ -169,7 +169,7 @@ Object {
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a message</p><p>New line</p>",
"html": "<p>This is a message</p><p></p><p>New line</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"likes_count": Any<Number>,
@ -239,7 +239,7 @@ exports[`Comments API when authenticated Can like a comment 2: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "648",
"content-length": "655",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -262,7 +262,7 @@ Object {
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a message</p><p>New line</p>",
"html": "<p>This is a message</p><p></p><p>New line</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"likes_count": Any<Number>,
@ -301,7 +301,7 @@ exports[`Comments API when authenticated Can like a comment 5: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "647",
"content-length": "654",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",
@ -473,7 +473,7 @@ Object {
Object {
"created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/,
"edited_at": null,
"html": "<p>This is a message</p><p>New line</p>",
"html": "<p>This is a message</p><p></p><p>New line</p>",
"id": StringMatching /\\[a-f0-9\\]\\{24\\}/,
"liked": Any<Boolean>,
"likes_count": Any<Number>,
@ -512,7 +512,7 @@ exports[`Comments API when authenticated Can remove a like 3: [headers] 1`] = `
Object {
"access-control-allow-origin": "*",
"cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0",
"content-length": "648",
"content-length": "655",
"content-type": "application/json; charset=utf-8",
"etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/,
"vary": "Accept-Encoding",

View File

@ -165,7 +165,7 @@ describe('Comments API', function () {
.post(`/api/comments/`)
.body({comments: [{
post_id: postId,
html: '<p>This is a <strong>message</strong></p><p>New line</p>'
html: '<div></div><p></p><p>This is a <strong>message</strong></p><p></p><p></p><p>New line</p><p></p>'
}]})
.expectStatus(201)
.matchHeaderSnapshot({
@ -184,7 +184,7 @@ describe('Comments API', function () {
subject: '💬 New comment on your post: ' + postTitle,
to: fixtureManager.get('users', 0).email,
// Note that the <strong> tag is removed by the sanitizer
html: new RegExp(escapeRegExp('<p>This is a message</p><p>New line</p>'))
html: new RegExp(escapeRegExp('<p>This is a message</p><p></p><p>New line</p>'))
});
// Wait for the dispatched events (because this happens async)
@ -391,7 +391,7 @@ describe('Comments API', function () {
mockManager.assert.sentEmail({
subject: '🚩 A comment has been reported on your post',
to: fixtureManager.get('users', 0).email,
html: new RegExp(escapeRegExp('<p>This is a message</p><p>New line</p>')),
html: new RegExp(escapeRegExp('<p>This is a message</p><p></p><p>New line</p>')),
text: new RegExp(escapeRegExp('This is a message\n\nNew line'))
});
});