Skip to content

Automated Test: embed-url-handling-post #319

Closed
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,10 @@ gem 'unicorn', require: false
gem 'puma', require: false
gem 'rbtrace', require: false

# required for feed importing and embedding
gem 'ruby-readability', require: false
gem 'simple-rss', require: false

# perftools only works on 1.9 atm
group :profile do
# travis refuses to install this, instead of fuffing, just avoid it for now
Expand Down
7 changes: 7 additions & 0 deletions Gemfile_rails4.lock
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,7 @@ GEM
fspath (2.0.5)
given_core (3.1.1)
sorcerer (>= 0.3.7)
guess_html_encoding (0.0.9)
handlebars-source (1.1.2)
hashie (2.0.5)
highline (1.6.20)
Expand Down Expand Up @@ -309,6 +310,9 @@ GEM
rspec-mocks (~> 2.14.0)
ruby-hmac (0.4.0)
ruby-openid (2.3.0)
ruby-readability (0.5.7)
guess_html_encoding (>= 0.0.4)
nokogiri (>= 1.4.2)
sanitize (2.0.6)
nokogiri (>= 1.4.4)
sass (3.2.12)
Expand Down Expand Up @@ -337,6 +341,7 @@ GEM
celluloid (>= 0.14.1)
ice_cube (~> 0.11.0)
sidekiq (~> 2.15.0)
simple-rss (1.3.1)
simplecov (0.7.1)
multi_json (~> 1.0)
simplecov-html (~> 0.7.1)
Expand Down Expand Up @@ -466,6 +471,7 @@ DEPENDENCIES
rinku
rspec-given
rspec-rails
ruby-readability
sanitize
sass
sass-rails
Expand All @@ -474,6 +480,7 @@ DEPENDENCIES
sidekiq (= 2.15.1)
sidekiq-failures
sidetiq (>= 0.3.6)
simple-rss
simplecov
sinatra
slim
Expand Down
27 changes: 27 additions & 0 deletions app/assets/javascripts/embed.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/* global discourseUrl */
/* global discourseEmbedUrl */
(function() {

var comments = document.getElementById('discourse-comments'),
iframe = document.createElement('iframe');
iframe.src = discourseUrl + "embed/best?embed_url=" + encodeURIComponent(discourseEmbedUrl);
iframe.id = 'discourse-embed-frame';
iframe.width = "100%";
iframe.frameBorder = "0";
iframe.scrolling = "no";
comments.appendChild(iframe);
Comment on lines +5 to +12

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add null check for the target DOM element.

If the discourse-comments element doesn't exist on the page, comments.appendChild(iframe) will throw an error. Consider adding defensive validation.

🛡️ Proposed fix
 var comments = document.getElementById('discourse-comments'),
     iframe = document.createElement('iframe');
+if (!comments) { return; }
 iframe.src = discourseUrl + "embed/best?embed_url=" + encodeURIComponent(discourseEmbedUrl);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var comments = document.getElementById('discourse-comments'),
iframe = document.createElement('iframe');
iframe.src = discourseUrl + "embed/best?embed_url=" + encodeURIComponent(discourseEmbedUrl);
iframe.id = 'discourse-embed-frame';
iframe.width = "100%";
iframe.frameBorder = "0";
iframe.scrolling = "no";
comments.appendChild(iframe);
var comments = document.getElementById('discourse-comments'),
iframe = document.createElement('iframe');
if (!comments) { return; }
iframe.src = discourseUrl + "embed/best?embed_url=" + encodeURIComponent(discourseEmbedUrl);
iframe.id = 'discourse-embed-frame';
iframe.width = "100%";
iframe.frameBorder = "0";
iframe.scrolling = "no";
comments.appendChild(iframe);
🤖 Prompt for AI Agents
In `@app/assets/javascripts/embed.js` around lines 5 - 12, The code assumes the
DOM element with id 'discourse-comments' exists and calls
comments.appendChild(iframe), which will throw if comments is null; update the
logic where comments is obtained (variable comments referencing
document.getElementById('discourse-comments')) to guard against null by checking
if comments is truthy before creating/appending the iframe (or return/exit
early), so only create iframe and set iframe.src/attributes and call
comments.appendChild(iframe) when comments is present.



function postMessageReceived(e) {
if (!e) { return; }
if (discourseUrl.indexOf(e.origin) === -1) { return; }

if (e.data) {
if (e.data.type === 'discourse-resize' && e.data.height) {
iframe.height = e.data.height + "px";
}
}
}
Comment on lines +15 to +24

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Origin validation is vulnerable to bypass attacks.

The indexOf check on line 17 can be bypassed by malicious origins. For example, if discourseUrl is "https://discourse.example.com", an attacker hosting content at "https://discourse.example.com.evil.com" would pass this check since indexOf would return 0.

🔒 Proposed fix for stricter origin validation
 function postMessageReceived(e) {
   if (!e) { return; }
-  if (discourseUrl.indexOf(e.origin) === -1) { return; }
+  // Extract origin from discourseUrl for strict comparison
+  var defined = document.createElement('a');
+  defined.href = discourseUrl;
+  var expectedOrigin = defined.protocol + '//' + defined.host;
+  if (e.origin !== expectedOrigin) { return; }

   if (e.data) {
🤖 Prompt for AI Agents
In `@app/assets/javascripts/embed.js` around lines 15 - 24, postMessageReceived
currently uses discourseUrl.indexOf(e.origin) which is vulnerable to
host-substring bypasses; change the origin check to compare origins exactly by
using the URL origin comparison (e.g. compute new URL(discourseUrl).origin and
compare it === e.origin) or maintain a whitelist array of allowed origins and
check e.origin against that list with strict equality; update the validation in
postMessageReceived to use e.origin === allowedOrigin (or
allowedOrigins.includes(e.origin)) and leave the rest of the message handling
(e.data.type === 'discourse-resize' and iframe.height assignment) unchanged.

window.addEventListener('message', postMessageReceived, false);

})();
69 changes: 69 additions & 0 deletions app/assets/stylesheets/embed.css.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
//= require ./vendor/normalize
//= require ./common/foundation/base

article.post {
border-bottom: 1px solid #ddd;

.post-date {
float: right;
color: #aaa;
font-size: 12px;
margin: 4px 4px 0 0;
}

.author {
padding: 20px 0;
width: 92px;
float: left;

text-align: center;

h3 {
text-align: center;
color: #4a6b82;
font-size: 13px;
margin: 0;
}
}

.cooked {
padding: 20px 0;
margin-left: 92px;

p {
margin: 0 0 1em 0;
}
}
}

header {
padding: 10px 10px 20px 10px;

font-size: 18px;

border-bottom: 1px solid #ddd;
}

footer {
font-size: 18px;

.logo {
margin-right: 10px;
margin-top: 10px;
}

a[href].button {
margin: 10px 0 0 10px;
}
}

.logo {
float: right;
max-height: 30px;
}

a[href].button {
background-color: #eee;
padding: 5px;
display: inline-block;
}
34 changes: 34 additions & 0 deletions app/controllers/embed_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
class EmbedController < ApplicationController
skip_before_filter :check_xhr
skip_before_filter :preload_json
before_filter :ensure_embeddable

layout 'embed'

def best
embed_url = params.require(:embed_url)
topic_id = TopicEmbed.topic_id_for_embed(embed_url)

if topic_id
@topic_view = TopicView.new(topic_id, current_user, {best: 5})
else
Jobs.enqueue(:retrieve_topic, user_id: current_user.try(:id), embed_url: embed_url)
render 'loading'
end

discourse_expires_in 1.minute
end

private

def ensure_embeddable
raise Discourse::InvalidAccess.new('embeddable host not set') if SiteSetting.embeddable_host.blank?
raise Discourse::InvalidAccess.new('invalid referer host') if URI(request.referer || '').host != SiteSetting.embeddable_host

response.headers['X-Frame-Options'] = "ALLOWALL"
rescue URI::InvalidURIError
raise Discourse::InvalidAccess.new('invalid referer host')
end


end
24 changes: 24 additions & 0 deletions app/jobs/regular/retrieve_topic.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
require_dependency 'email/sender'
require_dependency 'topic_retriever'

module Jobs

# Asynchronously retrieve a topic from an embedded site
class RetrieveTopic < Jobs::Base

def execute(args)
raise Discourse::InvalidParameters.new(:embed_url) unless args[:embed_url].present?

user = nil
if args[:user_id]
user = User.where(id: args[:user_id]).first
end

TopicRetriever.new(args[:embed_url], no_throttle: user.try(:staff?)).retrieve
end

end

end


41 changes: 41 additions & 0 deletions app/jobs/scheduled/poll_feed.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
#
# Creates and Updates Topics based on an RSS or ATOM feed.
#
require 'digest/sha1'
require_dependency 'post_creator'
require_dependency 'post_revisor'
require 'open-uri'

module Jobs
class PollFeed < Jobs::Scheduled
recurrence { hourly }
sidekiq_options retry: false

def execute(args)
poll_feed if SiteSetting.feed_polling_enabled? &&
SiteSetting.feed_polling_url.present? &&
SiteSetting.embed_by_username.present?
end

def feed_key
@feed_key ||= "feed-modified:#{Digest::SHA1.hexdigest(SiteSetting.feed_polling_url)}"
end

def poll_feed
user = User.where(username_lower: SiteSetting.embed_by_username.downcase).first
return if user.blank?

require 'simple-rss'
rss = SimpleRSS.parse open(SiteSetting.feed_polling_url)

rss.items.each do |i|
url = i.link
url = i.id if url.blank? || url !~ /^https?\:\/\//

content = CGI.unescapeHTML(i.content.scrub)
TopicEmbed.import(user, url, i.title, content)
end
end
Comment on lines +24 to +38

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing error handling and potential NoMethodError on nil content.

The poll_feed method lacks error handling for network failures and malformed feeds. Additionally, line 35 calls .scrub on i.content which will raise NoMethodError if the feed item has no content field.

🐛 Proposed fix with error handling and nil safety
     def poll_feed
       user = User.where(username_lower: SiteSetting.embed_by_username.downcase).first
       return if user.blank?
 
-      require 'simple-rss'
-      rss = SimpleRSS.parse open(SiteSetting.feed_polling_url)
-
-      rss.items.each do |i|
-        url = i.link
-        url = i.id if url.blank? || url !~ /^https?\:\/\//
-
-        content = CGI.unescapeHTML(i.content.scrub)
-        TopicEmbed.import(user, url, i.title, content)
+      begin
+        require 'simple-rss'
+        feed_response = URI.open(SiteSetting.feed_polling_url, read_timeout: 30, open_timeout: 10)
+        rss = SimpleRSS.parse(feed_response)
+
+        rss.items.each do |i|
+          url = i.link
+          url = i.id if url.blank? || url !~ /^https?\:\/\//
+
+          raw_content = i.content || i.description || ''
+          content = CGI.unescapeHTML(raw_content.to_s.scrub)
+          TopicEmbed.import(user, url, i.title, content)
+        end
+      rescue StandardError => e
+        Rails.logger.error("PollFeed failed: #{e.message}")
       end
     end
🧰 Tools
🪛 Brakeman (8.0.1)

[medium] 29-29: Model attribute used in file name
Type: File Access
Confidence: Medium
More info: https://brakemanscanner.org/docs/warning_types/file_access/

(File Access)

🤖 Prompt for AI Agents
In `@app/jobs/scheduled/poll_feed.rb` around lines 24 - 38, The poll_feed method
lacks network/feed error handling and calls i.content.scrub without nil checks;
wrap the feed fetch and parse (open(SiteSetting.feed_polling_url) and
SimpleRSS.parse) in a begin/rescue to catch and log exceptions, and skip
processing on failure, and before calling .scrub on i.content ensure i.content
is present (e.g., use a safe fallback like an empty string or i.description) so
TopicEmbed.import is never called with nil content; log any errors with context
(feed URL and exception) and continue processing remaining items.


end
end
9 changes: 9 additions & 0 deletions app/models/post.rb
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,10 @@ def self.types
@types ||= Enum.new(:regular, :moderator_action)
end

def self.cook_methods
@cook_methods ||= Enum.new(:regular, :raw_html)
end

def self.find_by_detail(key, value)
includes(:post_details).where(post_details: { key: key, value: value }).first
end
Expand Down Expand Up @@ -124,6 +128,11 @@ def post_analyzer
end

def cook(*args)
# For some posts, for example those imported via RSS, we support raw HTML. In that
# case we can skip the rendering pipeline.
return raw if cook_method == Post.cook_methods[:raw_html]

# Default is to cook posts
Plugin::Filter.apply(:after_post_cook, self, post_analyzer.cook(*args))
end

Expand Down
82 changes: 82 additions & 0 deletions app/models/topic_embed.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
require_dependency 'nokogiri'

class TopicEmbed < ActiveRecord::Base
belongs_to :topic
belongs_to :post
validates_presence_of :embed_url
validates_presence_of :content_sha1

# Import an article from a source (RSS/Atom/Other)
def self.import(user, url, title, contents)
return unless url =~ /^https?\:\/\//

contents << "\n<hr>\n<small>#{I18n.t('embed.imported_from', link: "<a href='#{url}'>#{url}</a>")}</small>\n"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Potential XSS: URL is interpolated into HTML without escaping.

The url parameter is directly embedded into the HTML string. While validated against an HTTP(S) regex, URLs can still contain characters that break out of the href attribute or introduce XSS payloads (e.g., javascript: protocol after redirects, or characters like " and >).

🛡️ Proposed fix using ERB::Util for escaping
+  require 'erb'
+
   # Import an article from a source (RSS/Atom/Other)
   def self.import(user, url, title, contents)
     return unless url =~ /^https?\:\/\//
 
-    contents << "\n<hr>\n<small>#{I18n.t('embed.imported_from', link: "<a href='#{url}'>#{url}</a>")}</small>\n"
+    escaped_url = ERB::Util.html_escape(url)
+    contents << "\n<hr>\n<small>#{I18n.t('embed.imported_from', link: "<a href='#{escaped_url}'>#{escaped_url}</a>")}</small>\n"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
contents << "\n<hr>\n<small>#{I18n.t('embed.imported_from', link: "<a href='#{url}'>#{url}</a>")}</small>\n"
require 'erb'
# Import an article from a source (RSS/Atom/Other)
def self.import(user, url, title, contents)
return unless url =~ /^https?\:\/\//
escaped_url = ERB::Util.html_escape(url)
contents << "\n<hr>\n<small>#{I18n.t('embed.imported_from', link: "<a href='#{escaped_url}'>#{escaped_url}</a>")}</small>\n"
end
🤖 Prompt for AI Agents
In `@app/models/topic_embed.rb` at line 13, The HTML injection risk comes from
interpolating the raw url into the I18n string in topic_embed.rb; change the
construction so the URL is HTML-escaped before embedding (e.g., use
ERB::Util.html_escape or CGI.escapeHTML on the url) and ensure the href uses a
validated/sanitized http(s) URI (parse and reject non-http(s) schemes) before
escaping; update the line that builds contents (the interpolation of url inside
I18n.t('embed.imported_from', link: ...)) to use the escaped/sanitized value so
the rendered <a href='...'> and link text cannot break out or inject XSS.


embed = TopicEmbed.where(embed_url: url).first
content_sha1 = Digest::SHA1.hexdigest(contents)
post = nil

# If there is no embed, create a topic, post and the embed.
if embed.blank?
Topic.transaction do
creator = PostCreator.new(user, title: title, raw: absolutize_urls(url, contents), skip_validations: true, cook_method: Post.cook_methods[:raw_html])
post = creator.create
if post.present?
TopicEmbed.create!(topic_id: post.topic_id,
embed_url: url,
content_sha1: content_sha1,
post_id: post.id)
end
end
else
post = embed.post
# Update the topic if it changed
if content_sha1 != embed.content_sha1
revisor = PostRevisor.new(post)
revisor.revise!(user, absolutize_urls(url, contents), skip_validations: true, bypass_rate_limiter: true)
embed.update_column(:content_sha1, content_sha1)
end
end

post
end

def self.import_remote(user, url, opts=nil)
require 'ruby-readability'

opts = opts || {}
doc = Readability::Document.new(open(url).read,
tags: %w[div p code pre h1 h2 h3 b em i strong a img],
attributes: %w[href src])

TopicEmbed.import(user, url, opts[:title] || doc.title, doc.content)
end
Comment on lines +44 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Security: SSRF vulnerability and missing error handling in import_remote.

Using open(url) directly with user-controlled URLs exposes the application to Server-Side Request Forgery (SSRF) attacks. An attacker could potentially access internal network resources. Additionally, there's no timeout, error handling, or content-length limit which could lead to resource exhaustion.

🔒 Proposed fix with validation and error handling
   def self.import_remote(user, url, opts=nil)
     require 'ruby-readability'
 
     opts = opts || {}
+    
+    begin
+      uri = URI.parse(url)
+      raise "Invalid URL scheme" unless %w[http https].include?(uri.scheme)
+      
+      # Add timeout and redirect limits
+      response = uri.open(read_timeout: 30, open_timeout: 10)
+      html_content = response.read(1.megabyte) # Limit content size
+    rescue StandardError => e
+      Rails.logger.error("Failed to fetch embed URL #{url}: #{e.message}")
+      return nil
+    end
+    
-    doc = Readability::Document.new(open(url).read,
+    doc = Readability::Document.new(html_content,
                                         tags: %w[div p code pre h1 h2 h3 b em i strong a img],
                                         attributes: %w[href src])
 
     TopicEmbed.import(user, url, opts[:title] || doc.title, doc.content)
   end
🤖 Prompt for AI Agents
In `@app/models/topic_embed.rb` around lines 44 - 53, The import_remote method
currently calls open(url) and Readability::Document without validation or error
handling; replace the direct open call in TopicEmbed.import_remote with a safe
fetch: validate and parse the URL (only allow http/https), resolve the host and
reject private/internal IPs, enforce a max redirect count, use a HTTP client
(e.g., Net::HTTP) with explicit open/read timeouts and a maximum allowed
content-length, and limit the HTML size read; wrap the fetch and
Readability::Document creation in a rescue block to handle timeouts, network
errors, invalid content, and oversized responses and return or raise a
controlled error instead of crashing, keeping the call site
TopicEmbed.import(user, url, opts[:title] || doc.title, doc.content) unchanged.


# Convert any relative URLs to absolute. RSS is annoying for this.
def self.absolutize_urls(url, contents)
uri = URI(url)
prefix = "#{uri.scheme}://#{uri.host}"
prefix << ":#{uri.port}" if uri.port != 80 && uri.port != 443

fragment = Nokogiri::HTML.fragment(contents)
fragment.css('a').each do |a|
href = a['href']
if href.present? && href.start_with?('/')
a['href'] = "#{prefix}/#{href.sub(/^\/+/, '')}"
end
end
fragment.css('img').each do |a|
src = a['src']
if src.present? && src.start_with?('/')
a['src'] = "#{prefix}/#{src.sub(/^\/+/, '')}"
end
end

fragment.to_html
end

def self.topic_id_for_embed(embed_url)
TopicEmbed.where(embed_url: embed_url).pluck(:topic_id).first
end

end
30 changes: 30 additions & 0 deletions app/views/embed/best.html.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<header>
<%- if @topic_view.posts.present? %>
<%= link_to(I18n.t('embed.title'), @topic_view.topic.url, class: 'button', target: '_blank') %>
<%- else %>
<%= link_to(I18n.t('embed.start_discussion'), @topic_view.topic.url, class: 'button', target: '_blank') %>
Comment on lines +3 to +5

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add rel="noopener noreferrer" to all _blank links.

Prevent reverse‑tabnabbing by adding rel on every link that opens a new tab.

🔒 Proposed fix
-    <%= link_to(I18n.t('embed.title'), `@topic_view.topic.url`, class: 'button', target: '_blank') %>
+    <%= link_to(I18n.t('embed.title'), `@topic_view.topic.url`, class: 'button', target: '_blank', rel: 'noopener noreferrer') %>
 ...
-    <%= link_to(I18n.t('embed.start_discussion'), `@topic_view.topic.url`, class: 'button', target: '_blank') %>
+    <%= link_to(I18n.t('embed.start_discussion'), `@topic_view.topic.url`, class: 'button', target: '_blank', rel: 'noopener noreferrer') %>
 ...
-      <%= link_to post.created_at.strftime("%e %b %Y"), post.url, class: 'post-date', target: "_blank" %>
+      <%= link_to post.created_at.strftime("%e %b %Y"), post.url, class: 'post-date', target: "_blank", rel: 'noopener noreferrer' %>
 ...
-    <%= link_to(I18n.t('embed.continue'), `@topic_view.topic.url`, class: 'button', target: '_blank') %>
+    <%= link_to(I18n.t('embed.continue'), `@topic_view.topic.url`, class: 'button', target: '_blank', rel: 'noopener noreferrer') %>

Also applies to: 14-14, 25-25

🤖 Prompt for AI Agents
In `@app/views/embed/best.html.erb` around lines 3 - 5, The two link_to calls that
open in a new tab (the ones calling I18n.t('embed.title') and
I18n.t('embed.start_discussion') with target: '_blank' and using
`@topic_view.topic.url`) need a rel attribute to prevent reverse-tabnabbing;
update each link_to invocation to include rel: 'noopener noreferrer' alongside
the existing class and target options.

<%- end if %>

<%= link_to(image_tag(SiteSetting.logo_url, class: 'logo'), Discourse.base_url) %>
</header>

<%- if @topic_view.posts.present? %>
<%- @topic_view.posts.each do |post| %>
<article class='post'>
<%= link_to post.created_at.strftime("%e %b %Y"), post.url, class: 'post-date', target: "_blank" %>
<div class='author'>
<img src='<%= post.user.small_avatar_url %>'>
<h3><%= post.user.username %></h3>
Comment on lines +16 to +17

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Add alt text to the avatar image.

This improves accessibility for screen‑reader users.

♿ Proposed fix
-        <img src='<%= post.user.small_avatar_url %>'>
+        <%= image_tag(post.user.small_avatar_url, alt: post.user.username, class: "avatar") %>
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<img src='<%= post.user.small_avatar_url %>'>
<h3><%= post.user.username %></h3>
<%= image_tag(post.user.small_avatar_url, alt: post.user.username, class: "avatar") %>
<h3><%= post.user.username %></h3>
🤖 Prompt for AI Agents
In `@app/views/embed/best.html.erb` around lines 16 - 17, The avatar <img> lacks
alt text; update the tag that uses post.user.small_avatar_url to include an alt
attribute (e.g., using post.user.username or a fallback like
"#{post.user.username} avatar") so screen readers receive a meaningful
description; ensure you use the same post.user.username symbol for the alt and
handle nil/blank username with a sensible default.

</div>
<div class='cooked'><%= raw post.cooked %></div>
<div style='clear: both'></div>
</article>
<%- end %>

<footer>
<%= link_to(I18n.t('embed.continue'), @topic_view.topic.url, class: 'button', target: '_blank') %>
<%= link_to(image_tag(SiteSetting.logo_url, class: 'logo'), Discourse.base_url) %>
</footer>

<% end %>

Loading