0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/app/views/topics/show.html.erb
Régis Hanol 75a57f129c
FIX: Prevent double-escaping of HTML entities in titles and meta descriptions (#41261)
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 `&rsquo;` and `&amp;` 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 `&hellip;` 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.
2026-07-02 20:26:35 +02:00

203 lines
9.6 KiB
Text
Vendored

<% if @topic_view %>
<div id="topic-title">
<h1>
<%= render_topic_title(@topic_view.topic) %>
</h1>
<% if @breadcrumbs.present? %>
<div class="topic-category" itemscope itemtype="http://schema.org/BreadcrumbList">
<% @breadcrumbs.each_with_index do |c, i| %>
<span itemprop="itemListElement" itemscope itemtype="http://schema.org/ListItem">
<a href="<%= c[:url] %>" class="badge-wrapper bullet" itemprop="item">
<span class='badge-category-bg' style='background-color: #<%= c[:color] %>'></span>
<span class='badge-category clear-badge'>
<span class='category-name' itemprop='name'><%= c[:name] %></span>
</span>
</a>
<meta itemprop="position" content="<%= i + 1 %>" />
</span>
<% end %>
</div>
<% end %>
<% if @tags.present? %>
<div class="topic-category">
<div class='discourse-tags list-tags'>
<% @tags.each_with_index do |tag, i| %>
<a href='<%= "#{Discourse.base_url}/tag/#{tag.name}" %>' class='discourse-tag' rel="tag"><%= tag.name -%></a><% if i < @tags.size - 1 %>, <% end %>
<% end %>
</div>
</div>
<% end %>
</div>
<%= server_plugin_outlet "topic_header" %>
<%- if include_crawler_content? %>
<% main_entity_schema = crawler_topic_main_entity_schema(@topic_view.topic) %>
<div <%= crawler_topic_container_schema(@topic_view.topic) %>>
<meta itemprop='<%= main_entity_schema ? "name" : "headline" %>' content='<%= @topic_view.title %>'>
<link itemprop='url' href='<%= @topic_view.absolute_url %>'>
<meta itemprop='datePublished' content='<%= @topic_view.topic.created_at.to_formatted_s(:iso8601) %>'>
<meta itemprop='keywords' content='<%= @tags.map(&:name).join(', ') %>'>
<% unless main_entity_schema %>
<% if @topic_view.topic.category.present? %>
<meta itemprop='articleSection' content='<%= @topic_view.topic.category.name %>'>
<% end %>
<div itemprop='publisher' itemscope itemtype="http://schema.org/Organization">
<meta itemprop='name' content='<%= SiteSetting.company_name.presence || SiteSetting.title %>'>
<% if application_logo_url.present? %>
<div itemprop='logo' itemscope itemtype="http://schema.org/ImageObject">
<meta itemprop='url' content='<%= application_logo_url %>'>
</div>
<% end %>
</div>
<% end %>
<% if main_entity_schema %>
<div <%= main_entity_schema %>>
<%= build_plugin_html 'server:topic-main-entity-meta-crawler' %>
<% end %>
<% if @topic_view.crawler_posts&.first && !@topic_view.crawler_posts.first.is_first_post? %>
<% if @topic_view.topic.user.present? %>
<span itemprop='author' itemscope itemtype="http://schema.org/Person">
<meta itemprop='name' content='<%= @topic_view.topic.user.username %>'>
<link itemprop='url' rel='nofollow' href='<%= Discourse.base_url %>/u/<%= @topic_view.topic.user.username %>'>
</span>
<% end %>
<meta itemprop='text' content='<%= @topic_view.topic.plain_text_excerpt %>'>
<% end %>
<% @topic_view.crawler_posts.each do |post| %>
<% next if crawler_post_schema_skip?(post, @topic_view.topic) %>
<% if (u = post.user) && !post.hidden && post.cooked && !post.cooked.strip.empty? %>
<% emit_md = crawler_post_emits_microdata?(post, @topic_view.topic) %>
<div id='post_<%= post.post_number %>' <%= crawler_post_schema(post, @topic_view.topic) %> class='topic-body crawler-post'>
<div class='crawler-post-meta'>
<span class="creator" <%= tag.attributes(itemprop: "author", itemscope: true, itemtype: "http://schema.org/Person") if emit_md %>>
<a rel='nofollow' href='<%= Discourse.base_url %>/u/<%= u.username %>'><span <%= tag.attributes(itemprop: "name") if emit_md %>><%= u.username %></span></a>
<% if emit_md %>
<meta itemprop='url' content='<%= Discourse.base_url %>/u/<%= u.username %>'>
<% end %>
<%= "(#{u.name})" if (SiteSetting.display_name_on_posts && SiteSetting.enable_names? && !u.name.blank?) %>
<%
post_custom_fields = @topic_view.post_custom_fields[post.id] || {}
who_username = post_custom_fields["action_code_who"] || ""
small_action_href = post_custom_fields["action_code_path"] || ""
if post.action_code
%>
<%= t("js.action_codes.#{post.action_code}", when: "", who: who_username, href: small_action_href).html_safe %>
<% end %>
</span>
<% if post.is_first_post? && !main_entity_schema %>
<link itemprop="mainEntityOfPage" href="<%= post.topic.url %>">
<% end %>
<% if post.image_url && emit_md %>
<link itemprop="image" href="<%= post.image_url %>">
<% end %>
<span class="crawler-post-infos">
<time <%= tag.attributes(itemprop: "datePublished") if emit_md && !post.is_first_post? %> datetime='<%= post.created_at.to_formatted_s(:iso8601) %>' class='post-time'>
<%= l post.created_at, format: :long %>
</time>
<% if emit_md %>
<% modified_at = post.version > 1 ? post.last_version_at : post.created_at %>
<meta itemprop='dateModified' content='<%= modified_at.to_formatted_s(:iso8601) %>'>
<% end %>
<span <%= tag.attributes(itemprop: "position") if emit_md %>><%= post.post_number %></span>
</span>
</div>
<div class='post' <%= tag.attributes(itemprop: "text") if emit_md %>>
<%= post.hidden ? t('flagging.user_must_edit').html_safe : post.cooked.html_safe %>
</div>
<% unless crawler_post_schema_overridden?(post, @topic_view.topic) %>
<div <%= tag.attributes(itemprop: "interactionStatistic", itemscope: true, itemtype: "http://schema.org/InteractionCounter") if emit_md %>>
<% if emit_md %>
<meta itemprop="interactionType" content="http://schema.org/LikeAction"/>
<meta itemprop="userInteractionCount" content="<%= post.like_count %>" />
<% end %>
<span class='post-likes'><%= post.like_count > 0 ? t('post.has_likes', count: post.like_count) : '' %></span>
</div>
<% end %>
<% if (linkbacks = @topic_view.linkbacks_for(post)).present? %>
<div class='crawler-linkback-list'>
<% linkbacks.each do |link| %>
<div>
<a href="<%=link[:url]%>"><%=link[:title]%></a>
</div>
<% end %>
</div>
<% end %>
<%= build_plugin_html "server:topic-show-crawler-post-end", post: post %>
</div>
<% end %>
<% end %>
<% if main_entity_schema %>
</div>
<% end %>
</div>
<% if @topic_view.single_post_request? || @topic_view.prev_page || @topic_view.next_page %>
<div role='navigation' itemscope itemtype='http://schema.org/SiteNavigationElement' class="topic-body crawler-post">
<% if @topic_view.single_post_request? %>
<span itemprop='name'>
<%= link_to t(:show_post_in_topic), "#{@topic_view.current_page_path}#post_#{@topic_view.crawler_posts.first&.post_number}", itemprop: 'url' %>
</span>
<% else %>
<% if @topic_view.prev_page %>
<span itemprop='name'><%= link_to t(:prev_page), @topic_view.prev_page_path, rel: 'prev', itemprop: 'url' %></span>
<% end %>
<% if @topic_view.next_page %>
<span itemprop='name'><b><%= link_to t(:next_page), @topic_view.next_page_path, rel: 'next', itemprop: 'url' %></b></span>
<% end %>
<% end %>
</div>
<% end %>
<%= build_plugin_html 'server:topic-show-after-posts-crawler' %>
<% else %>
<%= t(:crawler_content_hidden) %>
<% end %>
<% content_for :head do %>
<%= auto_discovery_link_tag(@topic_view, {action: :feed, slug: @topic_view.topic.slug, topic_id: @topic_view.topic.id}, rel: 'alternate nofollow', title: t('rss_posts_in_topic', topic: @topic_view.title), type: 'application/rss+xml') %>
<%= raw crawlable_meta_data(
title: @topic_view.title,
description: @topic_view.summary(strip_images: true),
image: @topic_view.image_url,
image_width: @topic_view.image_width,
image_height: @topic_view.image_height,
image_type: @topic_view.image_type,
read_time: @topic_view.read_time,
like_count: @topic_view.like_count,
ignore_canonical: true,
published_time: @topic_view.published_time,
breadcrumbs: @breadcrumbs,
tags: @tags.map(&:name),
) %>
<% if !@topic_view.single_post_request? && (@topic_view.prev_page || @topic_view.next_page) %>
<% if @topic_view.prev_page %>
<link rel="prev" href="<%= @topic_view.prev_page_path -%>">
<% end %>
<% if @topic_view.next_page %>
<link rel="next" href="<%= @topic_view.next_page_path -%>">
<% end %>
<% end %>
<% end %>
<% content_for(:title) { @title || "#{gsub_emoji_to_unicode(@topic_view.page_title)} - #{SiteSetting.title}" } %>
<% if @topic_view.print %>
<% content_for :after_body do %>
<%= preload_script('js/print-page') %>
<% end %>
<% end %>
<% end %>