fix: Prevent template variables from becoming links (#10725)
# Pull Request Template
## Description
**Issue**
This PR fixes template variables in messages (e.g., {{customer.name}})
that were being incorrectly converted to clickable links by the
`MessageFormatter's linkify` functionality. This caused formatting
issues and broken links.
**Solution**
Added a `linkify` parameter to `MessageFormatter` to optionally disable
link conversion
## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)
## How Has This Been Tested?
**Screenshots**
**Before**
<img width="1012" alt="image"
src="https://github.com/user-attachments/assets/70abb238-b4d9-439d-9e51-c7513cf482fb"
/>
**After**
<img width="1012" alt="image"
src="https://github.com/user-attachments/assets/387acb74-674e-4b26-85cc-2d7190d256b1"
/>
## Checklist:
- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [ ] Any dependent changes have been merged and published in downstream
modules
This commit is contained in:
@@ -98,7 +98,7 @@ const inboxIcon = computed(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-dompurify-html="formatMessage(message)"
|
v-dompurify-html="formatMessage(message, false, false, false)"
|
||||||
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
class="text-sm text-n-slate-11 line-clamp-1 [&>p]:mb-0 h-6"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
<div class="flex items-center w-full h-6 gap-2 overflow-hidden">
|
||||||
|
|||||||
@@ -30,6 +30,18 @@ describe('useMessageFormatter', () => {
|
|||||||
'<a href="https://twitter.com/hashtag/hashtag"'
|
'<a href="https://twitter.com/hashtag/hashtag"'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should disable link formatting when linkify is false', () => {
|
||||||
|
const message = 'Check https://example.com and {{user.id}}';
|
||||||
|
const result = messageFormatter.formatMessage(
|
||||||
|
message,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
expect(result).not.toContain('<a href="https://example.com"');
|
||||||
|
expect(result).toContain('{{user.id}}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('truncateMessage', () => {
|
describe('truncateMessage', () => {
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ export const useMessageFormatter = () => {
|
|||||||
* @param {boolean} isAPrivateNote - Whether the message is a private note.
|
* @param {boolean} isAPrivateNote - Whether the message is a private note.
|
||||||
* @returns {string} - The formatted message.
|
* @returns {string} - The formatted message.
|
||||||
*/
|
*/
|
||||||
const formatMessage = (message, isATweet, isAPrivateNote) => {
|
// TODO: ref:https://github.com/chatwoot/chatwoot/pull/10725#discussion_r1925300874
|
||||||
|
const formatMessage = (message, isATweet, isAPrivateNote, linkify) => {
|
||||||
const messageFormatter = new MessageFormatter(
|
const messageFormatter = new MessageFormatter(
|
||||||
message,
|
message,
|
||||||
isATweet,
|
isATweet,
|
||||||
isAPrivateNote
|
isAPrivateNote,
|
||||||
|
linkify
|
||||||
);
|
);
|
||||||
return messageFormatter.formattedMessage;
|
return messageFormatter.formattedMessage;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import mila from 'markdown-it-link-attributes';
|
import mila from 'markdown-it-link-attributes';
|
||||||
import mentionPlugin from './markdownIt/link';
|
import mentionPlugin from './markdownIt/link';
|
||||||
import MarkdownIt from 'markdown-it';
|
import MarkdownIt from 'markdown-it';
|
||||||
|
|
||||||
const setImageHeight = inlineToken => {
|
const setImageHeight = inlineToken => {
|
||||||
const imgSrc = inlineToken.attrGet('src');
|
const imgSrc = inlineToken.attrGet('src');
|
||||||
if (!imgSrc) return;
|
if (!imgSrc) return;
|
||||||
@@ -30,25 +31,27 @@ const imgResizeManager = md => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const md = MarkdownIt({
|
const createMarkdownInstance = (linkify = true) => {
|
||||||
html: false,
|
return MarkdownIt({
|
||||||
xhtmlOut: true,
|
html: false,
|
||||||
breaks: true,
|
xhtmlOut: true,
|
||||||
langPrefix: 'language-',
|
breaks: true,
|
||||||
linkify: true,
|
langPrefix: 'language-',
|
||||||
typographer: true,
|
linkify,
|
||||||
quotes: '\u201c\u201d\u2018\u2019',
|
typographer: true,
|
||||||
maxNesting: 20,
|
quotes: '\u201c\u201d\u2018\u2019',
|
||||||
})
|
maxNesting: 20,
|
||||||
.use(mentionPlugin)
|
})
|
||||||
.use(imgResizeManager)
|
.use(mentionPlugin)
|
||||||
.use(mila, {
|
.use(imgResizeManager)
|
||||||
attrs: {
|
.use(mila, {
|
||||||
class: 'link',
|
attrs: {
|
||||||
rel: 'noreferrer noopener nofollow',
|
class: 'link',
|
||||||
target: '_blank',
|
rel: 'noreferrer noopener nofollow',
|
||||||
},
|
target: '_blank',
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
const TWITTER_USERNAME_REGEX = /(^|[^@\w])@(\w{1,15})\b/g;
|
||||||
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
const TWITTER_USERNAME_REPLACEMENT = '$1[@$2](http://twitter.com/$2)';
|
||||||
@@ -56,10 +59,17 @@ const TWITTER_HASH_REGEX = /(^|\s)#(\w+)/g;
|
|||||||
const TWITTER_HASH_REPLACEMENT = '$1[#$2](https://twitter.com/hashtag/$2)';
|
const TWITTER_HASH_REPLACEMENT = '$1[#$2](https://twitter.com/hashtag/$2)';
|
||||||
|
|
||||||
class MessageFormatter {
|
class MessageFormatter {
|
||||||
constructor(message, isATweet = false, isAPrivateNote = false) {
|
constructor(
|
||||||
|
message,
|
||||||
|
isATweet = false,
|
||||||
|
isAPrivateNote = false,
|
||||||
|
linkify = true
|
||||||
|
) {
|
||||||
this.message = message || '';
|
this.message = message || '';
|
||||||
this.isAPrivateNote = isAPrivateNote;
|
this.isAPrivateNote = isAPrivateNote;
|
||||||
this.isATweet = isATweet;
|
this.isATweet = isATweet;
|
||||||
|
this.linkify = linkify;
|
||||||
|
this.md = createMarkdownInstance(linkify);
|
||||||
}
|
}
|
||||||
|
|
||||||
formatMessage() {
|
formatMessage() {
|
||||||
@@ -74,7 +84,7 @@ class MessageFormatter {
|
|||||||
TWITTER_HASH_REPLACEMENT
|
TWITTER_HASH_REPLACEMENT
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return md.render(updatedMessage);
|
return this.md.render(updatedMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
get formattedMessage() {
|
get formattedMessage() {
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ describe('#MessageFormatter', () => {
|
|||||||
'<p>Chatwoot is an opensource tool. <a href="https://www.chatwoot.com" class="link" rel="noreferrer noopener nofollow" target="_blank">https://www.chatwoot.com</a></p>'
|
'<p>Chatwoot is an opensource tool. <a href="https://www.chatwoot.com" class="link" rel="noreferrer noopener nofollow" target="_blank">https://www.chatwoot.com</a></p>'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
it('should not convert template variables to links when linkify is disabled', () => {
|
||||||
|
const message = 'Hey {{customer.name}}, check https://chatwoot.com';
|
||||||
|
const formatter = new MessageFormatter(message, false, false, false);
|
||||||
|
expect(formatter.formattedMessage).toMatch(
|
||||||
|
'<p>Hey {{customer.name}}, check https://chatwoot.com</p>'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('parses heading to strong', () => {
|
describe('parses heading to strong', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user