mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
Previously, content that was already HTML-escaped — topic `fancy_title`s on the 404/oops pages, GitHub onebox labels, and topic/group/category `<meta>` descriptions — got escaped a second time, so readers (and crawlers) saw literal entities like `’` and `&` instead of `'` and `&`. This change escapes every value exactly once, by converting it to plain text before it reaches the renderer instead of flagging anything `html_safe`: - `Emoji.codes_to_img` escapes its text segments with `html_escape_once` so the already-escaped `fancy_title` and sanitized GitHub labels aren't escaped again. All of its callers render into element content, and the attribute escaping inside `emoji_img_tag` is untouched. - the new `ExcerptParser.to_plain_text` converts a stored excerpt (escaped text, the `…` truncation marker, and the hashtag placeholder markup the parser keeps) back to plain text, exposed as `Topic#plain_text_excerpt`. It's a tag-strip plus a memoized `htmlentities` decode — pure Ruby, a few microseconds per excerpt, no Nokogiri in the hot path. - `Category#plain_text_description` caches the pre-escape plain text that `description_text` previously discarded, and the category meta descriptions use it directly. This also fixes the double-escaped `og:description`/`twitter:description` on category pages, where `gsub_emoji_to_unicode` silently drops the `html_safe` flag of `description_text`. - the new `Group#bio_summary` mirrors `UserProfile#bio_summary`: a plain-text excerpt of the bio with links and images stripped and hashtags rendered as plain `#slug`. Marking the stored values `html_safe` would have worked for the column data — every writer of `topics.excerpt` escapes — but `topic.excerpt` can be swapped in-memory with a localized excerpt that isn't guaranteed to be escaped, so treating everything as unsafe plain text is both simpler and safer.
326 lines
14 KiB
Ruby
Vendored
326 lines
14 KiB
Ruby
Vendored
# frozen_string_literal: true
|
||
|
||
require "excerpt_parser"
|
||
|
||
RSpec.describe ExcerptParser do
|
||
it "handles nested <details> blocks" do
|
||
html = <<~HTML.strip
|
||
<details>
|
||
<summary>FOO</summary>
|
||
<details>
|
||
<summary>BAR</summary>
|
||
<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>
|
||
</details>
|
||
</details>
|
||
HTML
|
||
|
||
expect(ExcerptParser.get_excerpt(html, 50, {})).to match_html "▶ FOO"
|
||
expect(ExcerptParser.get_excerpt(html, 6, {})).to match_html "▶ FOO"
|
||
expect(ExcerptParser.get_excerpt(html, 3, {})).to match_html "▶ FOO"
|
||
expect(ExcerptParser.get_excerpt(html, 2, {})).to match_html "▶ FO…"
|
||
end
|
||
|
||
it "allows <svg> with <use> inside for icons when keep_svg is true" do
|
||
html = '<svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, { keep_svg: true })).to match_html(
|
||
'<svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg>',
|
||
)
|
||
expect(ExcerptParser.get_excerpt(html, 100, {})).to match_html("")
|
||
|
||
html = '<svg class="blah"><use href="#folder"></use></svg>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, { keep_svg: true })).to match_html("")
|
||
|
||
html = '<svg><use href="#folder"></use></svg>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, { keep_svg: true })).to match_html("")
|
||
|
||
html =
|
||
'<use href="#user"></use><svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, { keep_svg: true })).to match_html(
|
||
'<svg class="fa d-icon d-icon-folder svg-icon svg-node"><use href="#folder"></use></svg>',
|
||
)
|
||
end
|
||
|
||
describe "keep_onebox_body parameter" do
|
||
it "keeps the body content for external oneboxes" do
|
||
html = <<~HTML.strip
|
||
<aside class="onebox">
|
||
<header class="source">
|
||
<img src="https://github.githubassets.com/favicon.ico" class="site-icon" width="32" height="32">
|
||
<a href="https://github.com/discourse/discourse" target="_blank">GitHub</a>
|
||
</header>
|
||
<article class="onebox-body">
|
||
<img src="/uploads/default/original/1X/10c0f1565ee5b6ca3fe43f3183529bc0afd26003.jpeg" class="thumbnail">
|
||
<h3>
|
||
<a href="https://github.com/discourse/discourse" target="_blank">discourse/discourse</a>
|
||
</h3>
|
||
<p>A platform for community discussion. Free, open, simple. - discourse/discourse</p>
|
||
</article>
|
||
</aside>
|
||
HTML
|
||
expect(ExcerptParser.get_excerpt(html, 100, keep_onebox_body: true)).to eq(<<~HTML.strip)
|
||
[image]
|
||
|
||
<a href="https://github.com/discourse/discourse" target="_blank">discourse/discourse</a>
|
||
|
||
A platform for community discussion. Free, o…
|
||
HTML
|
||
end
|
||
|
||
it "keeps the content for internal oneboxes" do
|
||
html = <<~HTML.strip
|
||
<aside class="quote" data-post="1" data-topic="8">
|
||
<div class="title">
|
||
<div class="quote-controls"></div>
|
||
<img width="20" height="20" src="/user_avatar/localhost/system/40/2_2.png" class="avatar">
|
||
<a href="/t/welcome-to-discourse/8/1">Welcome to Discourse</a>
|
||
</div>
|
||
<blockquote>The first paragraph of this pinned topic will be visible as a welcome message to all new visitors on your homepage.</blockquote>
|
||
</aside>
|
||
HTML
|
||
expect(ExcerptParser.get_excerpt(html, 100, keep_onebox_body: true)).to eq(<<~HTML.strip)
|
||
[image]
|
||
|
||
<a href="/t/welcome-to-discourse/8/1">Welcome to Discourse</a>
|
||
|
||
The first paragraph of this pinned topic will be …
|
||
HTML
|
||
end
|
||
|
||
it "keeps the content for internal oneboxes that contain github oneboxes" do
|
||
html = <<~HTML.strip
|
||
<p>Another commit to test out.</p>
|
||
<p>This time this commit has a longer commit message.</p>
|
||
<aside class="onebox githubcommit" data-onebox-src="https://github.com/discourse/discourse/commit/90f395a11895e9cfb7edd182b0bf5ec3d51d7892">
|
||
<header class="source">
|
||
<a href="https://github.com/discourse/discourse/commit/90f395a11895e9cfb7edd182b0bf5ec3d51d7892" target="_blank" rel="noopener">github.com/discourse/discourse</a>
|
||
</header>
|
||
<article class="onebox-body">
|
||
<div class="github-row">
|
||
<div class="github-icon-container" title="Commit">
|
||
<svg width="60" height="60" class="github-icon" viewBox="0 0 14 16" aria-hidden="true"><path fill-rule="evenodd" d="M10.86 7c-.45-1.72-2-3-3.86-3-1.86 0-3.41 1.28-3.86 3H0v2h3.14c.45 1.72 2 3 3.86 3 1.86 0 3.41-1.28 3.86-3H14V7h-3.14zM7 10.2c-1.22 0-2.2-.98-2.2-2.2 0-1.22.98-2.2 2.2-2.2 1.22 0 2.2.98 2.2 2.2 0 1.22-.98 2.2-2.2 2.2z"></path></svg>
|
||
</div>
|
||
<div class="github-info-container">
|
||
<h4>
|
||
<a href="https://github.com/discourse/discourse/commit/90f395a11895e9cfb7edd182b0bf5ec3d51d7892" target="_blank" rel="noopener">DEV: Skip srcset for onebox thumbnails (#22621)</a>
|
||
</h4>
|
||
<div class="github-info">
|
||
<div class="date">
|
||
committed <span class="discourse-local-date" data-format="ll" data-date="2023-07-19" data-time="18:21:34" data-timezone="UTC">06:21PM - 19 Jul 23 UTC (UTC)</span>
|
||
</div>
|
||
<div class="user">
|
||
<a href="https://github.com/oblakeerickson" target="_blank" rel="noopener">
|
||
<img alt="oblakeerickson" src="//localhost:3000/uploads/default/original/1X/741ac99d6a66d71cdd46dd99fb5156506e13fdf2.jpeg" class="onebox-avatar-inline" width="20" height="20" data-dominant-color="3C3C3C">
|
||
oblakeerickson
|
||
</a>
|
||
</div>
|
||
<div class="lines" title="changed 2 files with 24 additions and 15 deletions">
|
||
<a href="https://github.com/discourse/discourse/commit/90f395a11895e9cfb7edd182b0bf5ec3d51d7892" target="_blank" rel="noopener">
|
||
<span class="added">+24</span>
|
||
<span class="removed">-15</span>
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="github-row">
|
||
<p class="github-body-container">* DEV: Test commit message
|
||
This is a longer commit message <span class="show-more-container"><a href="https://github.com/discourse/discourse/commit/90f395a11895e9cfb7edd182b0bf5ec3d51d7892" target="_blank" rel="noopener" class="show-more">…</a></span><span class="excerpt hidden">that has the show-more class along with the exerpt hidden class</span></p>
|
||
</div>
|
||
</article>
|
||
<div class="onebox-metadata">
|
||
</div>
|
||
<div style="clear: both"></div>
|
||
</aside>
|
||
HTML
|
||
expect(ExcerptParser.get_excerpt(html, 100, keep_onebox_body: false)).to eq(<<~HTML.strip)
|
||
Another commit to test out. \nThis time this commit has a longer commit message.
|
||
HTML
|
||
end
|
||
end
|
||
|
||
describe "keep_quotes parameter" do
|
||
it "should keep the quoted content in html" do
|
||
html = <<~HTML.strip
|
||
<aside class="quote">
|
||
<blockquote>
|
||
This is a quoted text.
|
||
</blockquote>
|
||
</aside>
|
||
HTML
|
||
expect(ExcerptParser.get_excerpt(html, 100, keep_quotes: true)).to eq(
|
||
"This is a quoted text.",
|
||
)
|
||
end
|
||
end
|
||
|
||
describe "image handling options" do
|
||
describe "default behavior (no image option specified)" do
|
||
it "replaces images with alt text in brackets" do
|
||
html = '<p>Check out <img src="/uploads/image.jpg" alt="sunset"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100)).to eq("Check out [sunset]")
|
||
end
|
||
|
||
it "uses title text when alt is not present" do
|
||
html = '<p><img src="/uploads/image.jpg" title="My Image"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100)).to eq("[My Image]")
|
||
end
|
||
|
||
it "uses default image text when neither alt nor title is present" do
|
||
html = '<p><img src="/uploads/image.jpg"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100)).to eq("[image]")
|
||
end
|
||
|
||
it "handles multiple images" do
|
||
html =
|
||
'<p><img src="/uploads/1.jpg" alt="first"> and <img src="/uploads/2.jpg" alt="second"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100)).to eq("[first] and [second]")
|
||
end
|
||
|
||
it "does not include the URL" do
|
||
html = '<p><img src="/uploads/image.jpg" alt="photo"></p>'
|
||
result = ExcerptParser.get_excerpt(html, 100)
|
||
expect(result).to eq("[photo]")
|
||
expect(result).not_to include("/uploads/image.jpg")
|
||
end
|
||
end
|
||
|
||
describe "strip_images option" do
|
||
it "completely removes images with no replacement text" do
|
||
html = '<p>Check out this photo: <img src="/uploads/image.jpg" alt="sunset"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, strip_images: true)).to eq(
|
||
"Check out this photo:",
|
||
)
|
||
end
|
||
|
||
it "removes images regardless of alt or title attributes" do
|
||
html = '<p><img src="/uploads/image.jpg" title="My Image"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, strip_images: true)).to eq("")
|
||
end
|
||
|
||
it "removes images with no attributes" do
|
||
html = '<p><img src="/uploads/image.jpg"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, strip_images: true)).to eq("")
|
||
end
|
||
|
||
it "removes multiple images leaving only text" do
|
||
html =
|
||
'<p><img src="/uploads/1.jpg" alt="first"> and <img src="/uploads/2.jpg" alt="second"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, strip_images: true)).to eq("and")
|
||
end
|
||
|
||
it "still handles emoji images with keep_emoji_images" do
|
||
html =
|
||
'<p>Hello <img src="/images/emoji/emoji_one/smile.png" class="emoji" alt=":smile:"></p>'
|
||
expect(
|
||
ExcerptParser.get_excerpt(html, 100, strip_images: true, keep_emoji_images: true),
|
||
).to match(/<img.*class="emoji"/)
|
||
end
|
||
end
|
||
|
||
describe "markdown_images option" do
|
||
it "converts images to markdown format with alt text" do
|
||
html = '<p>Check out <img src="/uploads/image.jpg" alt="sunset"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, markdown_images: true)).to eq(
|
||
"Check out ",
|
||
)
|
||
end
|
||
|
||
it "uses title text when alt is not present" do
|
||
html = '<p><img src="/uploads/image.jpg" title="My Image"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, markdown_images: true)).to eq(
|
||
"",
|
||
)
|
||
end
|
||
|
||
it "uses default image text when neither alt nor title is present" do
|
||
html = '<p><img src="/uploads/image.jpg"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, markdown_images: true)).to eq(
|
||
"",
|
||
)
|
||
end
|
||
|
||
it "handles multiple images" do
|
||
html =
|
||
'<p><img src="/uploads/1.jpg" alt="first"> and <img src="/uploads/2.jpg" alt="second"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, markdown_images: true)).to eq(
|
||
" and ",
|
||
)
|
||
end
|
||
|
||
it "handles images with complex URLs" do
|
||
html = '<p><img src="https://example.com/path/to/image.jpg?size=large" alt="external"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, markdown_images: true)).to eq(
|
||
"",
|
||
)
|
||
end
|
||
end
|
||
|
||
describe "keep_images option" do
|
||
it "preserves the full img tag" do
|
||
html = '<p>Check out <img src="/uploads/image.jpg" alt="sunset" class="photo"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, keep_images: true)).to eq(
|
||
'Check out <img src="/uploads/image.jpg" alt="sunset" class="photo">',
|
||
)
|
||
end
|
||
|
||
it "preserves multiple attributes" do
|
||
html =
|
||
'<p><img src="/uploads/image.jpg" alt="sunset" title="Beautiful" width="100" height="100"></p>'
|
||
expect(ExcerptParser.get_excerpt(html, 100, keep_images: true)).to eq(
|
||
'<img src="/uploads/image.jpg" alt="sunset" title="Beautiful" width="100" height="100">',
|
||
)
|
||
end
|
||
|
||
it "preserves multiple images" do
|
||
html = '<p><img src="/1.jpg" alt="a"> <img src="/2.jpg" alt="b"></p>'
|
||
result = ExcerptParser.get_excerpt(html, 100, keep_images: true)
|
||
expect(result).to include('<img src="/1.jpg" alt="a">')
|
||
expect(result).to include('<img src="/2.jpg" alt="b">')
|
||
end
|
||
end
|
||
end
|
||
|
||
describe ".to_plain_text" do
|
||
it "decodes the entities emitted by the excerpt generator" do
|
||
excerpt =
|
||
ExcerptParser.get_excerpt("<p>Tom & Jerry’s \"tale\" of 1 < 2</p>", 100, {})
|
||
|
||
expect(excerpt).to eq("Tom & Jerry’s "tale" of 1 < 2")
|
||
expect(ExcerptParser.to_plain_text(excerpt)).to eq(%(Tom & Jerry’s "tale" of 1 < 2))
|
||
end
|
||
|
||
it "decodes the … truncation marker" do
|
||
excerpt = ExcerptParser.get_excerpt("<p>Lorem ipsum dolor</p>", 5, {})
|
||
|
||
expect(excerpt).to eq("Lorem…")
|
||
expect(ExcerptParser.to_plain_text(excerpt)).to eq("Lorem…")
|
||
end
|
||
|
||
it "keeps entities the user literally typed" do
|
||
excerpt = ExcerptParser.get_excerpt("<p>the &hellip; entity</p>", 100, {})
|
||
|
||
expect(ExcerptParser.to_plain_text(excerpt)).to eq("the … entity")
|
||
end
|
||
|
||
it "strips the markup the parser keeps for hashtags" do
|
||
html =
|
||
'<p>see <a class="hashtag-cooked" href="/c/general"><span class="hashtag-icon-placeholder"><svg class="fa d-icon"><use href="#folder"></use></svg></span><span>general</span></a> please</p>'
|
||
excerpt = ExcerptParser.get_excerpt(html, 100, strip_links: true, strip_images: true)
|
||
|
||
expect(excerpt).to include("<span")
|
||
expect(ExcerptParser.to_plain_text(excerpt)).to eq("see general please")
|
||
end
|
||
|
||
it "neutralizes markup in values that bypassed the generator" do
|
||
expect(ExcerptParser.to_plain_text(%(pwned "><script>alert(1)</script>))).to eq(
|
||
%(pwned ">alert(1)),
|
||
)
|
||
end
|
||
|
||
it "returns nil when nothing is left" do
|
||
expect(ExcerptParser.to_plain_text(nil)).to be_nil
|
||
expect(ExcerptParser.to_plain_text("")).to be_nil
|
||
expect(ExcerptParser.to_plain_text("<span></span>")).to be_nil
|
||
end
|
||
end
|
||
end
|