fix: Block inline images in message signatures (#13772)
# Pull Request Template ## Description This PR includes, block inline images in message signatures and prevent auto signature insertion when editor is disabled. - Strip inline base64 images from signature on save and show warning message - Add `INLINE_IMAGE_WARNING` translation key for signature inline image removal notification - Add disabled check to `addSignature()` to prevent signature insertion when editor is disabled - Add `isEditorDisabled` checks to signature toggle logic in `toggleSignatureForDraft()`, `replaceText()`, and `clearMessage()` - Remove unused `replaceText` from the codebase, which belongs to old `textarea` editor Fixes https://linear.app/chatwoot/issue/CW-6588/the-browser-hangs-when-the-message-signature-contains-inline-image ## Type of change - [x] Bug fix (non-breaking change which fixes an issue) ## How Has This Been Tested? ### Loom video https://www.loom.com/share/fb556b46a12a4308a737eed732d5ed73 ## Checklist: - [x] My code follows the style guidelines of this project - [x] I have performed a self-review of my code - [ ] 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 - [ ] 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 --------- Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
parent
e5107604a0
commit
699b12b1d3
@ -313,7 +313,12 @@ const plugins = computed(() => {
|
|||||||
const sendWithSignature = computed(() => {
|
const sendWithSignature = computed(() => {
|
||||||
// this is considered the source of truth, we watch this property
|
// this is considered the source of truth, we watch this property
|
||||||
// on change, we toggle the signature in the editor
|
// on change, we toggle the signature in the editor
|
||||||
if (props.allowSignature && !props.isPrivate && props.channelType) {
|
if (
|
||||||
|
props.allowSignature &&
|
||||||
|
!props.isPrivate &&
|
||||||
|
props.channelType &&
|
||||||
|
!props.disabled
|
||||||
|
) {
|
||||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function addSignature() {
|
function addSignature() {
|
||||||
|
if (props.disabled) return;
|
||||||
let content = props.modelValue;
|
let content = props.modelValue;
|
||||||
// see if the content is empty, if it is before appending the signature
|
// see if the content is empty, if it is before appending the signature
|
||||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||||
@ -454,6 +460,7 @@ function addSignature() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function removeSignature() {
|
function removeSignature() {
|
||||||
|
if (props.disabled) return;
|
||||||
if (!props.signature) return;
|
if (!props.signature) return;
|
||||||
let content = props.modelValue;
|
let content = props.modelValue;
|
||||||
content = removeSignatureHelper(
|
content = removeSignatureHelper(
|
||||||
@ -806,7 +813,7 @@ watch(
|
|||||||
|
|
||||||
watch(sendWithSignature, newValue => {
|
watch(sendWithSignature, newValue => {
|
||||||
// see if the allowSignature flag is true
|
// see if the allowSignature flag is true
|
||||||
if (props.allowSignature) {
|
if (props.allowSignature && !props.disabled) {
|
||||||
toggleSignatureInEditor(newValue);
|
toggleSignatureInEditor(newValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -128,7 +128,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
emits: [
|
emits: [
|
||||||
'replaceText',
|
|
||||||
'toggleInsertArticle',
|
'toggleInsertArticle',
|
||||||
'selectWhatsappTemplate',
|
'selectWhatsappTemplate',
|
||||||
'selectContentTemplate',
|
'selectContentTemplate',
|
||||||
@ -277,9 +276,6 @@ export default {
|
|||||||
toggleMessageSignature() {
|
toggleMessageSignature() {
|
||||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||||
},
|
},
|
||||||
replaceText(text) {
|
|
||||||
this.$emit('replaceText', text);
|
|
||||||
},
|
|
||||||
toggleInsertArticle() {
|
toggleInsertArticle() {
|
||||||
this.$emit('toggleInsertArticle');
|
this.$emit('toggleInsertArticle');
|
||||||
},
|
},
|
||||||
|
|||||||
@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
|||||||
import {
|
import {
|
||||||
getMessageVariables,
|
getMessageVariables,
|
||||||
getUndefinedVariablesInMessage,
|
getUndefinedVariablesInMessage,
|
||||||
replaceVariablesInMessage,
|
|
||||||
} from '@chatwoot/utils';
|
} from '@chatwoot/utils';
|
||||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||||
@ -636,10 +635,17 @@ export default {
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Even when editor is disabled (e.g. WhatsApp/API can't reply), we must
|
||||||
|
// still normalize stale signatures out of drafts when signature is off.
|
||||||
|
if (this.isEditorDisabled && this.sendWithSignature) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
const effectiveChannelType = getEffectiveChannelType(
|
const effectiveChannelType = getEffectiveChannelType(
|
||||||
this.channelType,
|
this.channelType,
|
||||||
this.inbox?.medium || ''
|
this.inbox?.medium || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.sendWithSignature
|
return this.sendWithSignature
|
||||||
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
? appendSignature(message, this.messageSignature, effectiveChannelType)
|
||||||
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
: removeSignature(message, this.messageSignature, effectiveChannelType);
|
||||||
@ -911,32 +917,6 @@ export default {
|
|||||||
});
|
});
|
||||||
this.hideContentTemplatesModal();
|
this.hideContentTemplatesModal();
|
||||||
},
|
},
|
||||||
replaceText(message) {
|
|
||||||
if (this.sendWithSignature && !this.private) {
|
|
||||||
// if signature is enabled, append it to the message
|
|
||||||
// appendSignature ensures that the signature is not duplicated
|
|
||||||
// so we don't need to check if the signature is already present
|
|
||||||
const effectiveChannelType = getEffectiveChannelType(
|
|
||||||
this.channelType,
|
|
||||||
this.inbox?.medium || ''
|
|
||||||
);
|
|
||||||
message = appendSignature(
|
|
||||||
message,
|
|
||||||
this.messageSignature,
|
|
||||||
effectiveChannelType
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const updatedMessage = replaceVariablesInMessage({
|
|
||||||
message,
|
|
||||||
variables: this.messageVariables,
|
|
||||||
});
|
|
||||||
|
|
||||||
setTimeout(() => {
|
|
||||||
useTrack(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
|
||||||
this.message = updatedMessage;
|
|
||||||
}, 100);
|
|
||||||
},
|
|
||||||
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
setReplyMode(mode = REPLY_EDITOR_MODES.REPLY) {
|
||||||
// Clear attachments when switching between private note and reply modes
|
// Clear attachments when switching between private note and reply modes
|
||||||
// This is to prevent from breaking the upload rules
|
// This is to prevent from breaking the upload rules
|
||||||
@ -1435,7 +1415,6 @@ export default {
|
|||||||
:new-conversation-modal-active="newConversationModalActive"
|
:new-conversation-modal-active="newConversationModalActive"
|
||||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||||
@select-content-template="openContentTemplateModal"
|
@select-content-template="openContentTemplateModal"
|
||||||
@replace-text="replaceText"
|
|
||||||
@toggle-insert-article="toggleInsertArticle"
|
@toggle-insert-article="toggleInsertArticle"
|
||||||
@toggle-quoted-reply="toggleQuotedReply"
|
@toggle-quoted-reply="toggleQuotedReply"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) {
|
|||||||
.trim(); // Trim any extra space
|
.trim(); // Trim any extra space
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes inline base64 markdown images from signature content.
|
||||||
|
*
|
||||||
|
* @param {string} content
|
||||||
|
* @returns {{ sanitizedContent: string, hasInlineImages: boolean }}
|
||||||
|
*/
|
||||||
|
export function stripInlineBase64Images(content) {
|
||||||
|
if (!content || typeof content !== 'string') {
|
||||||
|
return { sanitizedContent: content || '', hasInlineImages: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdownInlineBase64ImageRegex =
|
||||||
|
/!\[[^\]]*]\(\s*data:image\/[a-zA-Z0-9.+-]+;base64,[^)]+\s*\)/gi;
|
||||||
|
const sanitizedContent = content.replace(markdownInlineBase64ImageRegex, '');
|
||||||
|
const hasInlineImages = sanitizedContent !== content;
|
||||||
|
|
||||||
|
return { sanitizedContent, hasInlineImages };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Strip unsupported markdown formatting based on channel capabilities.
|
* Strip unsupported markdown formatting based on channel capabilities.
|
||||||
* Uses MARKDOWN_PATTERNS from editor constants.
|
* Uses MARKDOWN_PATTERNS from editor constants.
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import {
|
|||||||
getMenuAnchor,
|
getMenuAnchor,
|
||||||
calculateMenuPosition,
|
calculateMenuPosition,
|
||||||
stripUnsupportedFormatting,
|
stripUnsupportedFormatting,
|
||||||
|
stripInlineBase64Images,
|
||||||
} from '../editorHelper';
|
} from '../editorHelper';
|
||||||
import { FORMATTING } from 'dashboard/constants/editor';
|
import { FORMATTING } from 'dashboard/constants/editor';
|
||||||
import { EditorState } from '@chatwoot/prosemirror-schema';
|
import { EditorState } from '@chatwoot/prosemirror-schema';
|
||||||
@ -423,6 +424,36 @@ describe('extractTextFromMarkdown', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('stripInlineBase64Images', () => {
|
||||||
|
it('removes markdown data:image base64 images and sets hasInlineImages', () => {
|
||||||
|
const content =
|
||||||
|
'Hello\n\nWorld';
|
||||||
|
const { sanitizedContent, hasInlineImages } =
|
||||||
|
stripInlineBase64Images(content);
|
||||||
|
|
||||||
|
expect(hasInlineImages).toBe(true);
|
||||||
|
expect(sanitizedContent).not.toContain('data:image/png;base64');
|
||||||
|
expect(sanitizedContent).toContain('Hello');
|
||||||
|
expect(sanitizedContent).toContain('World');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves hosted image markdown unchanged', () => {
|
||||||
|
const content = '';
|
||||||
|
const { sanitizedContent, hasInlineImages } =
|
||||||
|
stripInlineBase64Images(content);
|
||||||
|
|
||||||
|
expect(hasInlineImages).toBe(false);
|
||||||
|
expect(sanitizedContent).toBe(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty hasInlineImages for empty input', () => {
|
||||||
|
expect(stripInlineBase64Images('')).toEqual({
|
||||||
|
sanitizedContent: '',
|
||||||
|
hasInlineImages: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('insertAtCursor', () => {
|
describe('insertAtCursor', () => {
|
||||||
it('should return undefined if editorView is not provided', () => {
|
it('should return undefined if editorView is not provided', () => {
|
||||||
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
const result = insertAtCursor(undefined, schema.text('Hello'), 0);
|
||||||
|
|||||||
@ -68,7 +68,8 @@
|
|||||||
"API_SUCCESS": "Signature saved successfully",
|
"API_SUCCESS": "Signature saved successfully",
|
||||||
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB",
|
||||||
|
"INLINE_IMAGE_WARNING": "Pasted inline images were removed. Please use the image upload button to add images to your signature."
|
||||||
},
|
},
|
||||||
"MESSAGE_SIGNATURE": {
|
"MESSAGE_SIGNATURE": {
|
||||||
"LABEL": "Message Signature",
|
"LABEL": "Message Signature",
|
||||||
|
|||||||
@ -1,5 +1,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, watch } from 'vue';
|
import { ref, watch } from 'vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { stripInlineBase64Images } from 'dashboard/helper/editorHelper';
|
||||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||||
import NextButton from 'dashboard/components-next/button/Button.vue';
|
import NextButton from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
@ -11,7 +14,9 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['updateSignature']);
|
const emit = defineEmits(['updateSignature']);
|
||||||
const signature = ref(props.messageSignature);
|
|
||||||
|
const { t } = useI18n();
|
||||||
|
const signature = ref(props.messageSignature ?? '');
|
||||||
watch(
|
watch(
|
||||||
() => props.messageSignature ?? '',
|
() => props.messageSignature ?? '',
|
||||||
newValue => {
|
newValue => {
|
||||||
@ -20,6 +25,15 @@ watch(
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updateSignature = () => {
|
const updateSignature = () => {
|
||||||
|
const { sanitizedContent, hasInlineImages } = stripInlineBase64Images(
|
||||||
|
signature.value || ''
|
||||||
|
);
|
||||||
|
signature.value = sanitizedContent.trim();
|
||||||
|
if (hasInlineImages) {
|
||||||
|
useAlert(
|
||||||
|
t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.INLINE_IMAGE_WARNING')
|
||||||
|
);
|
||||||
|
}
|
||||||
emit('updateSignature', signature.value);
|
emit('updateSignature', signature.value);
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user