Embed templates interpolate regex captures from user-authored article URLs into HTML attribute values. CommonMark's angle-bracket link destination syntax allows characters that the capture regexes don't filter, so the unescaped substitution could produce malformed attribute output. Escaping at substitution time keeps the render deterministic regardless of the URL. ### How was this tested? Added specs. Fixes [CW-6934](https://linear.app/chatwoot/issue/CW-6934/) Co-authored-by: Sony Mathew <sony@chatwoot.com> Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
261 lines
12 KiB
Ruby
261 lines
12 KiB
Ruby
require 'rails_helper'
|
|
|
|
describe CustomMarkdownRenderer do
|
|
let(:renderer) { described_class.new }
|
|
|
|
def render_markdown(markdown)
|
|
doc = CommonMarker.render_doc(markdown, :DEFAULT)
|
|
renderer.render(doc)
|
|
end
|
|
|
|
describe '#text' do
|
|
it 'converts text wrapped in ^ to superscript' do
|
|
markdown = 'This is an example of a superscript: ^superscript^.'
|
|
expect(render_markdown(markdown)).to include('<sup>superscript</sup>')
|
|
end
|
|
|
|
it 'does not convert text not wrapped in ^' do
|
|
markdown = 'This is an example without superscript.'
|
|
expect(render_markdown(markdown)).not_to include('<sup>')
|
|
end
|
|
|
|
it 'converts multiple superscripts in the same text' do
|
|
markdown = 'This is an example with ^multiple^ ^superscripts^.'
|
|
rendered_html = render_markdown(markdown)
|
|
expect(rendered_html.scan('<sup>').length).to eq(2)
|
|
expect(rendered_html).to include('<sup>multiple</sup>')
|
|
expect(rendered_html).to include('<sup>superscripts</sup>')
|
|
end
|
|
end
|
|
|
|
describe 'broken ^ usage' do
|
|
it 'does not convert text that only starts with ^' do
|
|
markdown = 'This is an example with ^broken superscript.'
|
|
expected_output = '<p>This is an example with ^broken superscript.</p>'
|
|
expect(render_markdown(markdown)).to include(expected_output)
|
|
end
|
|
|
|
it 'does not convert text that only ends with ^' do
|
|
markdown = 'This is an example with broken^ superscript.'
|
|
expected_output = '<p>This is an example with broken^ superscript.</p>'
|
|
expect(render_markdown(markdown)).to include(expected_output)
|
|
end
|
|
|
|
it 'does not convert text with uneven numbers of ^' do
|
|
markdown = 'This is an example with ^broken^ superscript^.'
|
|
expected_output = '<p>This is an example with <sup>broken</sup> superscript^.</p>'
|
|
expect(render_markdown(markdown)).to include(expected_output)
|
|
end
|
|
end
|
|
|
|
describe '#link' do
|
|
def render_markdown_link(link)
|
|
doc = CommonMarker.render_doc("[link](#{link})", :DEFAULT)
|
|
renderer.render(doc)
|
|
end
|
|
|
|
context 'when link is a YouTube URL' do
|
|
let(:youtube_url) { 'https://www.youtube.com/watch?v=VIDEO_ID' }
|
|
|
|
it 'renders an iframe with YouTube embed code' do
|
|
output = render_markdown_link(youtube_url)
|
|
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
|
expect(output).to include('allowfullscreen')
|
|
end
|
|
end
|
|
|
|
context 'when link is a Loom URL' do
|
|
let(:loom_url) { 'https://www.loom.com/share/VIDEO_ID' }
|
|
|
|
it 'renders an iframe with Loom embed code' do
|
|
output = render_markdown_link(loom_url)
|
|
expect(output).to include('src="https://www.loom.com/embed/VIDEO_ID"')
|
|
expect(output).to include('webkitallowfullscreen mozallowfullscreen allowfullscreen')
|
|
end
|
|
end
|
|
|
|
context 'when link is a Vimeo URL' do
|
|
let(:vimeo_url) { 'https://vimeo.com/1234567' }
|
|
|
|
it 'renders an iframe with Vimeo embed code' do
|
|
output = render_markdown_link(vimeo_url)
|
|
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
|
|
expect(output).to include('allowfullscreen')
|
|
end
|
|
end
|
|
|
|
context 'when link is an MP4 URL' do
|
|
let(:mp4_url) { 'https://example.com/video.mp4' }
|
|
|
|
it 'renders a video element with the MP4 source' do
|
|
output = render_markdown_link(mp4_url)
|
|
expect(output).to include('<video width="640" height="360" controls')
|
|
expect(output).to include('<source src="https://example.com/video.mp4" type="video/mp4">')
|
|
end
|
|
end
|
|
|
|
context 'when link is a normal URL' do
|
|
let(:normal_url) { 'https://example.com' }
|
|
|
|
it 'renders a normal link' do
|
|
output = render_markdown_link(normal_url)
|
|
expect(output).to include('<a href="https://example.com">')
|
|
end
|
|
end
|
|
|
|
context 'when multiple links are present' do
|
|
it 'renders all links when present between empty lines' do
|
|
markdown = "\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n\n[vimeo](https://vimeo.com/1234567)\n^ hello ^ [normal](https://example.com)"
|
|
output = render_markdown(markdown)
|
|
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
|
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
|
|
expect(output).to include('<a href="https://example.com">')
|
|
expect(output).to include('<sup> hello </sup>')
|
|
end
|
|
end
|
|
|
|
context 'when links within text are present' do
|
|
it 'renders only text within blank lines as embeds' do
|
|
markdown = "\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\nthis is such an amazing [vimeo](https://vimeo.com/1234567)\n[vimeo](https://vimeo.com/1234567)\n"
|
|
output = render_markdown(markdown)
|
|
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
|
expect(output).to include('src="https://player.vimeo.com/video/1234567?dnt=true"')
|
|
expect(output).to include('href="https://vimeo.com/1234567"')
|
|
end
|
|
end
|
|
|
|
context 'when link is an Arcade URL' do
|
|
let(:arcade_url) { 'https://app.arcade.software/share/ARCADE_ID' }
|
|
|
|
it 'renders an iframe with Arcade embed code' do
|
|
output = render_markdown_link(arcade_url)
|
|
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
|
|
expect(output).to include('<iframe')
|
|
expect(output).to include('webkitallowfullscreen')
|
|
expect(output).to include('mozallowfullscreen')
|
|
expect(output).to include('allowfullscreen')
|
|
end
|
|
|
|
it 'wraps iframe in responsive container' do
|
|
output = render_markdown_link(arcade_url)
|
|
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
|
|
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
|
end
|
|
end
|
|
|
|
context 'when link is an Arcade tab URL' do
|
|
let(:arcade_tab_url) { 'https://app.arcade.software/share/ARCADE_TAB_ID?embed_mobile=tab' }
|
|
|
|
it 'renders an iframe with Arcade tab embed code' do
|
|
output = render_markdown_link(arcade_tab_url)
|
|
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
|
|
end
|
|
|
|
it 'supports additional query params after embed_mobile' do
|
|
url = 'https://app.arcade.software/share/ARCADE_TAB_ID?foo=bar&embed_mobile=tab?user_id=1'
|
|
output = render_markdown_link(url)
|
|
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_TAB_ID?embed&embed_mobile=tab"')
|
|
end
|
|
|
|
it 'wraps iframe in responsive container' do
|
|
output = render_markdown_link(arcade_tab_url)
|
|
expect(output).to include('position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;')
|
|
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
|
end
|
|
end
|
|
|
|
context 'when link is a wistia URL' do
|
|
let(:wistia_url) { 'https://chatwoot.wistia.com/medias/kjwjeq6f9i' }
|
|
|
|
it 'renders a custom element with Wistia embed code' do
|
|
output = render_markdown_link(wistia_url)
|
|
expect(output).to include('<script src="https://fast.wistia.com/player.js" async></script>')
|
|
expect(output).to include('<wistia-player')
|
|
expect(output).to include('media-id="kjwjeq6f9i"')
|
|
end
|
|
end
|
|
|
|
context 'when multiple links including Arcade are present' do
|
|
it 'renders Arcade embed along with other content types' do
|
|
markdown = "\n[arcade](https://app.arcade.software/share/ARCADE_ID)\n\n[youtube](https://www.youtube.com/watch?v=VIDEO_ID)\n"
|
|
output = render_markdown(markdown)
|
|
expect(output).to include('src="https://app.arcade.software/embed/ARCADE_ID"')
|
|
expect(output).to include('src="https://www.youtube-nocookie.com/embed/VIDEO_ID"')
|
|
end
|
|
end
|
|
|
|
context 'when link is a GuideJar embed URL' do
|
|
let(:guidejar_url) { 'https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA' }
|
|
|
|
it 'renders an iframe with GuideJar embed code' do
|
|
output = render_markdown_link(guidejar_url)
|
|
expect(output).to include('src="https://www.guidejar.com/embed/i2qMQRp26rtRxpZczmaA?type=1&controls=on"')
|
|
expect(output).to include('allowfullscreen')
|
|
end
|
|
end
|
|
|
|
context 'when link is a GuideJar guides URL' do
|
|
let(:guidejar_url) { 'https://guidejar.com/guides/d6a6fdc2-4812-4777-897e-ec1b0c64238f' }
|
|
|
|
it 'renders an iframe with GuideJar embed code' do
|
|
output = render_markdown_link(guidejar_url)
|
|
expect(output).to include('src="https://www.guidejar.com/embed/d6a6fdc2-4812-4777-897e-ec1b0c64238f?type=1&controls=on"')
|
|
expect(output).to include('allowfullscreen')
|
|
end
|
|
|
|
it 'wraps iframe in responsive container' do
|
|
output = render_markdown_link(guidejar_url)
|
|
expect(output).to include('position: relative; padding-bottom: 62.5%; height: 0;')
|
|
expect(output).to include('position: absolute; top: 0; left: 0; width: 100%; height: 100%;')
|
|
end
|
|
end
|
|
|
|
context 'when link is a Bunny.net iframe URL' do
|
|
let(:bunny_url) { 'https://iframe.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
|
|
|
it 'renders an iframe with Bunny embed code' do
|
|
output = render_markdown_link(bunny_url)
|
|
expect(output).to include('src="https://player.mediadelivery.net/embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c?autoplay=false&loop=false&muted=false&preload=true&responsive=true"')
|
|
expect(output).to include('allowfullscreen')
|
|
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
|
end
|
|
|
|
it 'wraps iframe in responsive container' do
|
|
output = render_markdown_link(bunny_url)
|
|
expect(output).to include('position: relative; padding-top: 56.25%;')
|
|
expect(output).to include('position: absolute; top: 0; height: 100%; width: 100%;')
|
|
end
|
|
end
|
|
|
|
context 'when link is a Bunny.net player URL' do
|
|
let(:bunny_url) { 'https://player.mediadelivery.net/play/431789/1f105841-cad9-46fe-a70e-b7623c60797c' }
|
|
|
|
it 'renders an iframe with Bunny embed code' do
|
|
output = render_markdown_link(bunny_url)
|
|
expect(output).to include('embed/431789/1f105841-cad9-46fe-a70e-b7623c60797c')
|
|
expect(output).to include('autoplay=false&loop=false&muted=false&preload=true&responsive=true')
|
|
expect(output).to include('allowfullscreen')
|
|
expect(output).to include('allow="accelerometer; gyroscope; autoplay; encrypted-media; picture-in-picture;"')
|
|
end
|
|
end
|
|
|
|
context 'when captured values contain HTML-special characters' do
|
|
# CommonMark angle-bracket link destinations `[text](<URL>)` permit characters
|
|
# like `"` that the embed regex captures would otherwise pass through raw into
|
|
# attribute values. Captures are HTML-escaped before interpolation so the
|
|
# substituted value cannot break out of the surrounding attribute context.
|
|
it 'escapes double quotes in captured YouTube video_id' do
|
|
markdown = "\n[demo](<https://www.youtube.com/watch?v=x\" onload=\"alert(1)>)\n"
|
|
output = render_markdown(markdown)
|
|
expect(output).not_to include('onload="alert(1)"')
|
|
expect(output).to include('"')
|
|
end
|
|
|
|
it 'leaves legitimate alphanumeric IDs untouched' do
|
|
output = render_markdown_link('https://www.youtube.com/watch?v=dQw4w9WgXcQ')
|
|
expect(output).to include('src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"')
|
|
end
|
|
end
|
|
end
|
|
end
|