From 1beaa284c60d2ae0313e4ca1cda70871e18a2d15 Mon Sep 17 00:00:00 2001 From: Sivin Varghese <64252451+iamsivin@users.noreply.github.com> Date: Wed, 3 Jun 2026 15:05:17 +0530 Subject: [PATCH] feat: inline images in website and email channels (#14516) # Pull Request Template ## Description This PR adds support for inline image uploads in the reply editor for Email and Website (chat widget) channels. Agents can now insert images inline between text and resize them directly in the editor by dragging the bottom corner, similar to the help center editor experience. Image sizes are preserved through markdown using the `cw_image_width` URL param and render correctly in both outgoing emails and chat widget messages. Agents can also paste copied images directly into Email or Website replies using **Shift+Cmd+V** (Shift+Ctrl+V on Windows/Linux). The image gets inserted inline at the cursor position and supports resizing just like uploaded images. Regular **Cmd+V / Ctrl+V** behavior remains unchanged and continues to add images as attachments, so both inline and attachment flows are supported. ### Prosemirror repo PR: https://github.com/chatwoot/prosemirror-schema/pull/48 Fixes https://linear.app/chatwoot/issue/CW-7133/inline-images-in-live-chat-and-email https://linear.app/chatwoot/issue/CW-7225/ghsa-8j9w-jppp-xcfc-html-attribute-injection-via-unvalidated-cw-image ## Type of change - [x] New feature (non-breaking change which adds functionality) ## How Has This Been Tested? ### Screencast https://github.com/user-attachments/assets/a928f852-ab15-413a-9d35-6ea69b718ecf image ## 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 - [ ] 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 | 164 ++++++++---------- app/javascript/dashboard/constants/editor.js | 22 +-- .../dashboard/helper/editorHelper.js | 25 --- .../helper/specs/editorHelper.spec.js | 66 ------- .../i18n/locale/en/conversation.json | 1 + .../settings/profile/MessageSignature.vue | 1 - .../shared/helpers/MessageFormatter.js | 25 ++- lib/base_markdown_renderer.rb | 32 +++- package.json | 2 +- pnpm-lock.yaml | 10 +- spec/lib/base_markdown_renderer_spec.rb | 29 +++- 11 files changed, 150 insertions(+), 227 deletions(-) diff --git a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue index 3da526fc0a7..a8f2d0a2c9d 100644 --- a/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue +++ b/app/javascript/dashboard/components/widgets/WootWriter/Editor.vue @@ -32,7 +32,6 @@ import { CONVERSATION_EVENTS, CAPTAIN_EVENTS, } from 'dashboard/helper/AnalyticsHelper/events'; -import { MESSAGE_EDITOR_IMAGE_RESIZES } from 'dashboard/constants/editor'; import { messageSchema, @@ -43,6 +42,7 @@ import { MessageMarkdownSerializer, EditorState, Selection, + imageResizeView, } from '@chatwoot/prosemirror-schema'; import { suggestionsPlugin, @@ -57,7 +57,6 @@ import { insertAtCursor, removeSignature as removeSignatureHelper, scrollCursorIntoView, - setURLWithQueryAndSize, getFormattingForEditor, getSelectionCoords, calculateMenuPosition, @@ -72,6 +71,7 @@ import { import { createTypingIndicator } from '@chatwoot/utils'; import { checkFileSizeLimit } from 'shared/helpers/FileHelper'; import { uploadFile } from 'dashboard/helper/uploadHelper'; +import { INBOX_TYPES } from 'dashboard/helper/inbox'; const props = defineProps({ modelValue: { type: String, default: '' }, @@ -93,7 +93,6 @@ const props = defineProps({ channelType: { type: String, default: '' }, conversationId: { type: Number, default: null }, medium: { type: String, default: '' }, - showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar focusOnMount: { type: Boolean, default: true }, }); @@ -119,6 +118,14 @@ const TYPING_INDICATOR_IDLE_TIME = 4000; const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB const DEFAULT_FORMATTING = 'Context::Default'; const PRIVATE_NOTE_FORMATTING = 'Context::PrivateNote'; +const MESSAGE_SIGNATURE_FORMATTING = 'Context::MessageSignature'; +const INLINE_IMAGE_PASTE_TYPES = [ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/webp', +]; const effectiveChannelType = computed(() => getEffectiveChannelType(props.channelType, props.medium) @@ -192,12 +199,8 @@ const cannedSearchTerm = ref(''); const variableSearchTerm = ref(''); const emojiSearchTerm = ref(''); const range = ref(null); -const isImageNodeSelected = ref(false); -const toolbarPosition = ref({ top: 0, left: 0 }); -const selectedImageNode = ref(null); const isTextSelected = ref(false); // Tracks text selection and prevents unnecessary re-renders on mouse selection const showSelectionMenu = ref(false); -const sizes = MESSAGE_EDITOR_IMAGE_RESIZES; // element ref const editorRoot = useTemplateRef('editorRoot'); @@ -475,16 +478,6 @@ function removeSignature() { reloadState(content); } -function setToolbarPosition() { - const editorRect = editorRoot.value.getBoundingClientRect(); - const rect = selectedImageNode.value.getBoundingClientRect(); - - toolbarPosition.value = { - top: `${rect.top - editorRect.top - 30}px`, - left: `${rect.left - editorRect.left - 4}px`, - }; -} - function setMenubarPosition({ selection } = {}) { const wrapper = editorRoot.value; if (!selection || !wrapper) return; @@ -520,30 +513,6 @@ function checkSelection(editorState) { if (hasSelection) setMenubarPosition(editorState); } -function setURLWithQueryAndImageSize(size) { - if (!props.showImageResizeToolbar) { - return; - } - setURLWithQueryAndSize(selectedImageNode.value, size, editorView); - isImageNodeSelected.value = false; -} - -function isEditorMouseFocusedOnAnImage() { - if (!props.showImageResizeToolbar) { - return; - } - selectedImageNode.value = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (selectedImageNode.value) { - isImageNodeSelected.value = !!selectedImageNode.value; - // Get the position of the selected node - setToolbarPosition(); - } else { - isImageNodeSelected.value = false; - } -} - function emitOnChange() { emit('input', contentFromEditor()); emit('update:modelValue', contentFromEditor()); @@ -563,21 +532,6 @@ function toggleSignatureInEditor(signatureEnabled) { emitOnChange(); } -function updateImgToolbarOnDelete() { - // check if the selected node is present or not on keyup - // this is needed because the user can select an image and then delete it - // in that case, the selected node will be null and we need to hide the toolbar - // otherwise, the toolbar will be visible even when the image is deleted and cause some errors - if (selectedImageNode.value) { - const hasImgSelectedNode = document.querySelector( - 'img.ProseMirror-selectednode' - ); - if (!hasImgSelectedNode) { - isImageNodeSelected.value = false; - } - } -} - function isEnterToSendEnabled() { return isEditorHotKeyEnabled('enter'); } @@ -586,17 +540,6 @@ function isCmdPlusEnterToSendEnabled() { return isEditorHotKeyEnabled('cmd_enter'); } -useKeyboardEvents({ - 'Alt+KeyP': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, - 'Alt+KeyL': { - action: focusEditorInputField, - allowOnFocusedInput: false, - }, -}); - function onImageInsertInEditor(fileUrl) { const { tr } = editorView.state; @@ -617,7 +560,11 @@ async function uploadImageToStorage(file) { onImageInsertInEditor(fileUrl); } useAlert( - t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS') + props.channelType === MESSAGE_SIGNATURE_FORMATTING + ? t( + 'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS' + ) + : t('CONVERSATION.REPLYBOX.IMAGE_UPLOAD_SUCCESS') ); } catch (error) { useAlert( @@ -626,8 +573,8 @@ async function uploadImageToStorage(file) { } } -function onFileChange() { - const file = imageUpload.value.files[0]; +function uploadImageIfWithinSizeLimit(file) { + if (!file) return; if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) { uploadImageToStorage(file); } else { @@ -640,10 +587,61 @@ function onFileChange() { ) ); } - - imageUpload.value = ''; } +function onFileChange() { + const input = imageUpload.value; + uploadImageIfWithinSizeLimit(input.files[0]); + input.value = ''; +} + +const allowsInlineImagePaste = computed( + () => + !props.isPrivate && + (props.channelType === INBOX_TYPES.EMAIL || + props.channelType === INBOX_TYPES.WEB) +); + +// Shift+Cmd/Ctrl+V on email/website: upload a clipboard image inline. This +// gesture's native paste event carries no image, so clipboard.read() is the +// only way to get the bytes. No preventDefault: text still pastes natively. +async function pasteInlineImageFromClipboard() { + if (!editorView?.hasFocus()) return; + if (!allowsInlineImagePaste.value || !navigator.clipboard?.read) return; + try { + const items = await navigator.clipboard.read(); + const imageItem = items.find(item => + item.types.some(type => INLINE_IMAGE_PASTE_TYPES.includes(type)) + ); + if (!imageItem) return; + const imageType = imageItem.types.find(type => + INLINE_IMAGE_PASTE_TYPES.includes(type) + ); + const blob = await imageItem.getType(imageType); + uploadImageIfWithinSizeLimit( + new File([blob], 'pasted-image', { type: imageType }) + ); + } catch (error) { + // clipboard-read denied/unfocused (NotAllowedError): image can't be read. + // Text paste is unaffected — ProseMirror handles it from the native event. + } +} + +useKeyboardEvents({ + 'Alt+KeyP': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + 'Alt+KeyL': { + action: focusEditorInputField, + allowOnFocusedInput: false, + }, + '$mod+Shift+KeyV': { + action: pasteInlineImageFromClipboard, + allowOnFocusedInput: true, + }, +}); + function handleLineBreakWhenEnterToSendEnabled(event) { if ( hasPressedEnterAndNotCmdOrShift(event) && @@ -736,6 +734,9 @@ function createEditorView() { editorView = new EditorView(editor.value, { state: state, editable: () => !props.disabled, + nodeViews: { + image: imageResizeView, + }, dispatchTransaction: tx => { state = state.apply(tx); editorView.updateState(state); @@ -748,12 +749,10 @@ function createEditorView() { keyup: () => { if (!props.disabled) { typingIndicator.start(); - updateImgToolbarOnDelete(); } }, keydown: (view, event) => !props.disabled && onKeydown(event), focus: () => !props.disabled && emit('focus'), - click: () => !props.disabled && isEditorMouseFocusedOnAnImage(), blur: () => { if (props.disabled) return; typingIndicator.stop(); @@ -918,23 +917,6 @@ useEmitter(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, insertContentIntoEditor); @change="onFileChange" />
-
- -
diff --git a/app/javascript/dashboard/constants/editor.js b/app/javascript/dashboard/constants/editor.js index 378d303b48d..5f4fe0e0332 100644 --- a/app/javascript/dashboard/constants/editor.js +++ b/app/javascript/dashboard/constants/editor.js @@ -14,6 +14,7 @@ export const FORMATTING = { 'link', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -30,6 +31,7 @@ export const FORMATTING = { 'strike', 'bulletList', 'orderedList', + 'imageUpload', 'undo', 'redo', ], @@ -263,23 +265,3 @@ export const MARKDOWN_PATTERNS = [ ], }, ]; - -// Editor image resize options for Message Editor -export const MESSAGE_EDITOR_IMAGE_RESIZES = [ - { - name: 'Small', - height: '24px', - }, - { - name: 'Medium', - height: '48px', - }, - { - name: 'Large', - height: '72px', - }, - { - name: 'Original Size', - height: 'auto', - }, -]; diff --git a/app/javascript/dashboard/helper/editorHelper.js b/app/javascript/dashboard/helper/editorHelper.js index e4330b332c2..32f56172a4f 100644 --- a/app/javascript/dashboard/helper/editorHelper.js +++ b/app/javascript/dashboard/helper/editorHelper.js @@ -379,31 +379,6 @@ export const findNodeToInsertImage = (editorState, fileUrl) => { }; }; -/** - * Set URL with query and size. - * - * @param {Object} selectedImageNode - The current selected node. - * @param {Object} size - The size to set. - * @param {Object} editorView - The editor view. - */ -export function setURLWithQueryAndSize(selectedImageNode, size, editorView) { - if (selectedImageNode) { - // Create and apply the transaction - const tr = editorView.state.tr.setNodeMarkup( - editorView.state.selection.from, - null, - { - src: selectedImageNode.src, - height: size.height, - } - ); - - if (tr.docChanged) { - editorView.dispatch(tr); - } - } -} - /** * Strips unsupported markdown formatting from content based on the editor schema. * This ensures canned responses with rich formatting can be inserted into channels diff --git a/app/javascript/dashboard/helper/specs/editorHelper.spec.js b/app/javascript/dashboard/helper/specs/editorHelper.spec.js index 00d1e83d43b..7d32f63e05c 100644 --- a/app/javascript/dashboard/helper/specs/editorHelper.spec.js +++ b/app/javascript/dashboard/helper/specs/editorHelper.spec.js @@ -16,7 +16,6 @@ import { insertAtCursor, removeSignature, replaceSignature, - setURLWithQueryAndSize, stripInlineBase64Images, stripUnsupportedFormatting, stripUnsupportedMarkdown, @@ -653,71 +652,6 @@ describe('findNodeToInsertImage', () => { }); }); -describe('setURLWithQueryAndSize', () => { - let selectedNode; - let editorView; - - beforeEach(() => { - selectedNode = { - setAttribute: vi.fn(), - }; - - const tr = { - setNodeMarkup: vi.fn().mockReturnValue({ - docChanged: true, - }), - }; - - const state = { - selection: { from: 0 }, - tr, - }; - - editorView = { - state, - dispatch: vi.fn(), - }; - }); - - it('updates the URL with the given size and updates the editor view', () => { - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('updates the URL with the given size and updates the editor view with original size', () => { - const size = { height: 'auto' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view is updated - expect(editorView.dispatch).toHaveBeenCalledTimes(1); - }); - - it('does not update the editor view if the document has not changed', () => { - editorView.state.tr.setNodeMarkup = vi.fn().mockReturnValue({ - docChanged: false, - }); - - const size = { height: '20px' }; - - setURLWithQueryAndSize(selectedNode, size, editorView); - - // Check if the editor view dispatch was not called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); - - it('does not perform any operations if selectedNode is not provided', () => { - setURLWithQueryAndSize(null, { height: '20px' }, editorView); - - // Ensure the dispatch method wasn't called - expect(editorView.dispatch).not.toHaveBeenCalled(); - }); -}); - describe('getContentNode', () => { let mockEditorView; diff --git a/app/javascript/dashboard/i18n/locale/en/conversation.json b/app/javascript/dashboard/i18n/locale/en/conversation.json index 7b1b49493a8..3eb83fd311b 100644 --- a/app/javascript/dashboard/i18n/locale/en/conversation.json +++ b/app/javascript/dashboard/i18n/locale/en/conversation.json @@ -233,6 +233,7 @@ "TIP_AUDIORECORDER_ERROR": "Could not open the audio", "AUDIO_CONVERSION_FAILED": "Audio conversion failed. Please try again.", "DRAG_DROP": "Drag and drop here to attach", + "IMAGE_UPLOAD_SUCCESS": "Image uploaded successfully", "START_AUDIO_RECORDING": "Start audio recording", "STOP_AUDIO_RECORDING": "Stop audio recording", "COPILOT_THINKING": "Copilot is thinking", diff --git a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue index b0dab9774f3..bf6f01f8241 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/profile/MessageSignature.vue @@ -48,7 +48,6 @@ const updateSignature = () => { :placeholder="$t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE.PLACEHOLDER')" channel-type="Context::MessageSignature" :enable-suggestions="false" - show-image-resize-toolbar />
{ +const setImageSizing = inlineToken => { const imgSrc = inlineToken.attrGet('src'); if (!imgSrc) return; const url = new URL(imgSrc); + const width = url.searchParams.get('cw_image_width'); + if (width) { + inlineToken.attrSet( + 'style', + `width: ${width}; max-width: 100%; height: auto;` + ); + return; + } const height = url.searchParams.get('cw_image_height'); - if (!height) return; - inlineToken.attrSet('style', `height: ${height};`); + if (height) inlineToken.attrSet('style', `height: ${height};`); }; const processInlineToken = blockToken => { blockToken.children.forEach(inlineToken => { if (inlineToken.type === 'image') { - setImageHeight(inlineToken); + setImageSizing(inlineToken); } }); }; const imgResizeManager = md => { - // Custom rule for image resize in markdown - // If the image url has a query param cw_image_height, then add a style attribute to the image - md.core.ruler.after('inline', 'add-image-height', state => { + // If the image URL carries a cw_image_width or cw_image_height query param, + // add an inline style attribute so the rendered respects the agent's + // resize choice. Width takes precedence (HC drag-resize); height is kept for + // legacy messages and the message-signature use case. + md.core.ruler.after('inline', 'add-image-sizing', state => { state.tokens.forEach(blockToken => { if (blockToken.type === 'inline') { processInlineToken(blockToken); diff --git a/lib/base_markdown_renderer.rb b/lib/base_markdown_renderer.rb index f530e71ee5d..2329dea6d6e 100644 --- a/lib/base_markdown_renderer.rb +++ b/lib/base_markdown_renderer.rb @@ -1,9 +1,9 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer def image(node) src, title = extract_img_attributes(node) - height = extract_image_height(src) + sizing_style = extract_image_sizing_style(src) - render_img_tag(src, title, height) + render_img_tag(src, title, sizing_style) end private @@ -15,9 +15,25 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer ] end - def extract_image_height(src) + # Drag-resize from the reply editor encodes the chosen width as cw_image_width + # on the URL; the older message-signature picker uses cw_image_height. Width + # wins when both are set so the agent's most recent intent is honored. + def extract_image_sizing_style(src) query_params = parse_query_params(src) - query_params['cw_image_height']&.first + width = sanitize_pixel_value(query_params['cw_image_width']&.first) + return "width: #{width}; max-width: 100%; height: auto;" if width + + height = sanitize_pixel_value(query_params['cw_image_height']&.first) + height ? "height: #{height};" : nil + end + + # Only allow a bounded `px` value so the decoded query param can't + # break out of the inline style attribute (HTML attribute injection). + def sanitize_pixel_value(raw) + return unless raw =~ /\A(\d+)px\z/ + + px = Regexp.last_match(1).to_i + "#{px}px" if px.between?(1, 2000) end def parse_query_params(url) @@ -27,13 +43,13 @@ class BaseMarkdownRenderer < CommonMarker::HtmlRenderer {} end - def render_img_tag(src, title, height = nil) + def render_img_tag(src, title, sizing_style = nil) title_attribute = title.present? ? " title=\"#{title}\"" : '' - # Use inline style instead of the HTML height attribute: email clients and - # the in-app Letter view both run images through CSS (e.g. prose / + # Use inline style instead of HTML width/height attributes: email clients + # and the in-app Letter view both run images through CSS (e.g. prose / # lettersanitizer's `img { height: auto }`) which overrides presentational # attributes. Inline style has higher specificity and survives. - style_attribute = height ? " style=\"height: #{height};\"" : '' + style_attribute = sizing_style ? " style=\"#{sizing_style}\"" : '' plain do # plain ensures that the content is not wrapped in a paragraph tag diff --git a/package.json b/package.json index 081c706c60f..d8527051dab 100644 --- a/package.json +++ b/package.json @@ -34,7 +34,7 @@ "@amplitude/analytics-browser": "^2.11.10", "@breezystack/lamejs": "^1.2.7", "@chatwoot/ninja-keys": "1.2.3", - "@chatwoot/prosemirror-schema": "1.3.13", + "@chatwoot/prosemirror-schema": "1.3.17", "@chatwoot/utils": "^0.0.55", "@formkit/core": "^1.7.2", "@formkit/vue": "^1.7.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index afcf5b60f30..a4b61061c50 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -25,8 +25,8 @@ importers: specifier: 1.2.3 version: 1.2.3 '@chatwoot/prosemirror-schema': - specifier: 1.3.13 - version: 1.3.13 + specifier: 1.3.17 + version: 1.3.17 '@chatwoot/utils': specifier: ^0.0.55 version: 0.0.55 @@ -458,8 +458,8 @@ packages: '@chatwoot/ninja-keys@1.2.3': resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==} - '@chatwoot/prosemirror-schema@1.3.13': - resolution: {integrity: sha512-T6FBUinMJbwDCD7975g8M/Tsn2+G3O2pTGIXdcLkMRpbAAC6mVdl4ZcZektlt5y/PVmPVqNHPsfee1XB/C3vAw==} + '@chatwoot/prosemirror-schema@1.3.17': + resolution: {integrity: sha512-n78ZfMIzSrylImIN5cjCeEdTJ8ub0JtCybwUlqFyOyLy3ZzAZpOHvCSo+w/KmV4dCgOH2mBmYlxBQ9Rww+e0Rw==} '@chatwoot/utils@0.0.55': resolution: {integrity: sha512-8G6HYQe1ZEYfJEsSYfDVvE+uhf98JDRjtGlpB+bzMko+yltbrk4yACSo/ImC3jSaJ6K8yPTSjJToSRmsQbL2iQ==} @@ -5128,7 +5128,7 @@ snapshots: hotkeys-js: 3.8.7 lit: 2.2.6 - '@chatwoot/prosemirror-schema@1.3.13': + '@chatwoot/prosemirror-schema@1.3.17': dependencies: markdown-it-sup: 2.0.0 prosemirror-commands: 1.7.1 diff --git a/spec/lib/base_markdown_renderer_spec.rb b/spec/lib/base_markdown_renderer_spec.rb index f8bdae4beee..082f9fff6c6 100644 --- a/spec/lib/base_markdown_renderer_spec.rb +++ b/spec/lib/base_markdown_renderer_spec.rb @@ -11,8 +11,33 @@ describe BaseMarkdownRenderer do describe '#image' do context 'when image has a height' do it 'renders the img tag with the correct attributes' do - markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100)' - expect(render_markdown(markdown)).to include('') + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_height=100px)' + expect(render_markdown(markdown)).to include('') + end + end + + context 'when image has a width' do + it 'renders the img tag with the correct attributes' do + markdown = '![Sample Title](https://example.com/image.jpg?cw_image_width=200px)' + expect(render_markdown(markdown)).to include( + '' + ) + end + end + + context 'when the sizing param contains an attribute-injection payload' do + it 'drops the malicious height value' do + markdown = '![x](https://example.com/image.jpg?cw_image_height=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') + end + + it 'drops the malicious width value' do + markdown = '![x](https://example.com/image.jpg?cw_image_width=1px%22%20onmouseover%3D%22alert(1))' + rendered = render_markdown(markdown) + expect(rendered).not_to include('style=') + expect(rendered).not_to include('onmouseover="') end end