From 699b12b1d39759e31200e93f57836ae9d00872f2 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:17:19 +0530 Subject: [PATCH] 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 --- .../components/widgets/WootWriter/Editor.vue | 11 ++++-- .../widgets/WootWriter/ReplyBottomPanel.vue | 4 --- .../widgets/conversation/ReplyBox.vue | 35 ++++--------------- .../dashboard/helper/editorHelper.js | 19 ++++++++++ .../helper/specs/editorHelper.spec.js | 31 ++++++++++++++++ .../dashboard/i18n/locale/en/settings.json | 3 +- .../settings/profile/MessageSignature.vue | 16 ++++++++- 7 files changed, 83 insertions(+), 36 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 2a9577644..1bf08d169 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -313,7 +313,12 @@ const plugins = computed(() => { const sendWithSignature = computed(() => { // this is considered the source of truth, we watch this property // 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); } @@ -436,6 +441,7 @@ function reloadState(content = props.modelValue) { } function addSignature() { + if (props.disabled) return; let content = props.modelValue; // 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 @@ -454,6 +460,7 @@ function addSignature() { } function removeSignature() { + if (props.disabled) return; if (!props.signature) return; let content = props.modelValue; content = removeSignatureHelper( @@ -806,7 +813,7 @@ watch( watch(sendWithSignature, newValue => { // see if the allowSignature flag is true - if (props.allowSignature) { + if (props.allowSignature && !props.disabled) { toggleSignatureInEditor(newValue); } }); diff --git a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue index 5f76041dc..ff569d763 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue @@ -128,7 +128,6 @@ export default { }, }, emits: [ - 'replaceText', 'toggleInsertArticle', 'selectWhatsappTemplate', 'selectContentTemplate', @@ -277,9 +276,6 @@ export default { toggleMessageSignature() { this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature); }, - replaceText(text) { - this.$emit('replaceText', text); - }, toggleInsertArticle() { this.$emit('toggleInsertArticle'); }, diff --git a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue index ef6fa03d6..a5122094e 100644 --- a/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue +++ b/app/javascript/dashboard/components/widgets/conversation/ReplyBox.vue @@ -27,7 +27,6 @@ import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events'; import { getMessageVariables, getUndefinedVariablesInMessage, - replaceVariablesInMessage, } from '@chatwoot/utils'; import WhatsappTemplates from './WhatsappTemplates/Modal.vue'; import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue'; @@ -636,10 +635,17 @@ export default { 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( this.channelType, this.inbox?.medium || '' ); + return this.sendWithSignature ? appendSignature(message, this.messageSignature, effectiveChannelType) : removeSignature(message, this.messageSignature, effectiveChannelType); @@ -911,32 +917,6 @@ export default { }); 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) { // Clear attachments when switching between private note and reply modes // This is to prevent from breaking the upload rules @@ -1435,7 +1415,6 @@ export default { :new-conversation-modal-active="newConversationModalActive" @select-whatsapp-template="openWhatsappTemplateModal" @select-content-template="openContentTemplateModal" - @replace-text="replaceText" @toggle-insert-article="toggleInsertArticle" @toggle-quoted-reply="toggleQuotedReply" /> diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index 25d062650..b3d071ccd 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -32,6 +32,25 @@ export function extractTextFromMarkdown(markdown) { .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. * Uses MARKDOWN_PATTERNS from editor constants. diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index ca61c1bab..f558dd213 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -15,6 +15,7 @@ import { getMenuAnchor, calculateMenuPosition, stripUnsupportedFormatting, + stripInlineBase64Images, } from '../editorHelper'; import { FORMATTING } from 'dashboard/constants/editor'; 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![x](data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAE)\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 = '![](https://example.com/logo.png)'; + 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', () => { it('should return undefined if editorView is not provided', () => { const result = insertAtCursor(undefined, schema.text('Hello'), 0); diff --git a/app/javascript/dashboard/i18n/locale/en/settings.json b/app/javascript/dashboard/i18n/locale/en/settings.json index d885cf8ce..bfbd920a7 100644 --- a/app/javascript/dashboard/i18n/locale/en/settings.json +++ b/app/javascript/dashboard/i18n/locale/en/settings.json @@ -68,7 +68,8 @@ "API_SUCCESS": "Signature saved successfully", "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_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": { "LABEL": "Message Signature", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue index fbaed06c1..b0dab9774 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue @@ -1,5 +1,8 @@