Skip to content

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

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

Missing null-guard on discourse-comments element and no trailing-slash normalization on discourseUrl.

Two defensive concerns:

  1. If document.getElementById('discourse-comments') returns null, comments.appendChild(iframe) throws a TypeError. A guard check avoids a hard failure on pages that don't include the expected container.

  2. The URL concatenation discourseUrl + "embed/best?..." assumes discourseUrl ends with /. If it doesn't (e.g., "http://example.com"), the resulting URL is "http://example.comembed/best?...".

Suggested fix
- var comments = document.getElementById('discourse-comments'),
-     iframe = document.createElement('iframe');
- iframe.src = discourseUrl + "embed/best?embed_url=" + encodeURIComponent(discourseEmbedUrl);
+ var comments = document.getElementById('discourse-comments'),
+     iframe = document.createElement('iframe'),
+     baseUrl = discourseUrl.replace(/\/?$/, '/');
+ if (!comments) { return; }
+ iframe.src = baseUrl + "embed/best?embed_url=" + encodeURIComponent(discourseEmbedUrl);
🤖 Prompt for AI Agents
In `@app/assets/javascripts/embed.js` around lines 5 - 12, The code assumes
document.getElementById('discourse-comments') always returns an element and that
discourseUrl ends with a slash; add a null-guard and normalize discourseUrl:
fetch the container via document.getElementById('discourse-comments') and if it
is null, no-op (or bail out) before creating/appending the iframe, and ensure
discourseUrl ends with '/' (append one if missing) before building iframe.src
using discourseEmbedUrl; update references:
document.getElementById('discourse-comments'), comments,
comments.appendChild(iframe), iframe.src, discourseUrl, and discourseEmbedUrl.



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 via indexOf is insecure — allows prefix-matching bypass.

discourseUrl.indexOf(e.origin) is a substring check: any origin that is a prefix of discourseUrl will pass. For example, if discourseUrl is "http://discourse.example.com/", a malicious page at http://discourse.example.co would pass because indexOf returns 0.

A strict origin comparison should be used instead:

Suggested fix
  function postMessageReceived(e) {
    if (!e) { return; }
-   if (discourseUrl.indexOf(e.origin) === -1) { return; }
+   var defined = document.createElement('a');
+   defined.href = discourseUrl;
+   if (e.origin !== (defined.protocol + '//' + defined.host)) { return; }
📝 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
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";
}
}
}
function postMessageReceived(e) {
if (!e) { return; }
var defined = document.createElement('a');
defined.href = discourseUrl;
if (e.origin !== (defined.protocol + '//' + defined.host)) { return; }
if (e.data) {
if (e.data.type === 'discourse-resize' && e.data.height) {
iframe.height = e.data.height + "px";
}
}
}
🤖 Prompt for AI Agents
In `@app/assets/javascripts/embed.js` around lines 15 - 24, The origin check in
postMessageReceived is insecure because it uses discourseUrl.indexOf(e.origin);
replace that with a strict origin comparison (e.origin === discourseUrl) or
compare against a whitelist of allowed origins, ensuring you reference the same
discourseUrl and e.origin variables; update the conditional in the
postMessageReceived function to only accept exact matches before processing
e.data and setting iframe.height.

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)

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

SSRF / command injection risk: Kernel#open with user-controlled string.

open(SiteSetting.feed_polling_url) uses Kernel#open, which will execute shell commands if the string starts with |. Even though this is admin-configured, use a safer alternative. Brakeman also flagged this.

Proposed fix
-      rss = SimpleRSS.parse open(SiteSetting.feed_polling_url)
+      rss = SimpleRSS.parse URI.open(SiteSetting.feed_polling_url)
🧰 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` at line 29, Replace the unsafe Kernel#open
call by validating and fetching the feed URL using URI and a direct HTTP client:
parse SiteSetting.feed_polling_url with URI.parse, ensure the scheme is "http"
or "https", fetch the response body with Net::HTTP (or a similar HTTP client)
and then pass that body string to SimpleRSS.parse; update the code around
SimpleRSS.parse and SiteSetting.feed_polling_url to perform the scheme
validation and HTTP fetch and add basic error handling for invalid URIs or
network failures.


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
Comment on lines +31 to +37

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

i.content may be nil — NoMethodError on .scrub.

Not all RSS/Atom items have a content element. If i.content is nil, calling .scrub will raise NoMethodError. Add a nil guard or use i.content_encoded / i.description as fallbacks.

Proposed fix
       rss.items.each do |i|
         url = i.link
         url = i.id if url.blank? || url !~ /^https?\:\/\//
 
-        content = CGI.unescapeHTML(i.content.scrub)
+        raw_content = i.content || i.description
+        next if raw_content.blank?
+        content = CGI.unescapeHTML(raw_content.scrub)
         TopicEmbed.import(user, url, i.title, content)
       end
🤖 Prompt for AI Agents
In `@app/jobs/scheduled/poll_feed.rb` around lines 31 - 37, i.content can be nil
causing a NoMethodError when calling .scrub; update the RSS item handling in the
loop so you normalize the item body before scrubbing/using it — e.g. derive a
string body by checking i.content, then fallback to i.content_encoded or
i.description (or empty string), call .to_s.scrub on that result, and pass the
cleaned string into TopicEmbed.import along with url and i.title; ensure the
nil-guarding happens before calling .scrub and reference the loop handling where
i.content is accessed and the TopicEmbed.import call.

end

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'

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

require_dependency is for Discourse's autoloaded files, not gems.

Nokogiri is an external gem and should be loaded with require, not require_dependency. require_dependency is a Rails autoloading mechanism for application code.

Proposed fix
-require_dependency 'nokogiri'
+require 'nokogiri'
📝 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
require_dependency 'nokogiri'
require 'nokogiri'
🤖 Prompt for AI Agents
In `@app/models/topic_embed.rb` at line 1, The file app/models/topic_embed.rb
currently uses require_dependency 'nokogiri' which is incorrect for loading an
external gem; replace the call to require_dependency with a plain require
'nokogiri' so Nokogiri is loaded as a gem (edit the top of topic_embed.rb where
require_dependency 'nokogiri' appears and change it to require '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

XSS risk: url is interpolated into HTML without escaping.

If the url contains characters like ', >, or ", it could break out of the <a> tag and inject arbitrary HTML. Additionally, the << operator mutates the caller's contents string in-place, which can cause surprising side effects.

Proposed fix
-    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 = 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"
escaped_url = ERB::Util.html_escape(url)
contents = contents + "\n<hr>\n<small>#{I18n.t('embed.imported_from', link: "<a href='#{escaped_url}'>#{escaped_url}</a>")}</small>\n"
🤖 Prompt for AI Agents
In `@app/models/topic_embed.rb` at line 13, The code interpolates raw `url` into
HTML via contents << ... which risks XSS and also mutates the `contents` string;
fix by HTML-escaping the `url` before interpolation (e.g., use
ERB::Util.html_escape or CGI.escapeHTML) and avoid in-place mutation by building
a new string (e.g., assign contents = contents + ... or use a non-mutating
concatenation) when calling I18n.t('embed.imported_from', link: ...), so the
link value is escaped and `contents` is not mutated.


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 | 🟠 Major

open(url) is unsafe — potential SSRF and has no timeout or size limit.

Kernel#open (or OpenURI) with an untrusted URL can be exploited for SSRF. In older Ruby versions, URLs starting with | execute shell commands. Even with well-formed URLs, there's no timeout or response size cap, so a malicious or slow server could hang the process or exhaust memory.

Consider using OpenURI explicitly with timeouts, or a more robust HTTP client:

Proposed fix
  def self.import_remote(user, url, opts=nil)
    require 'ruby-readability'
+   require 'open-uri'

    opts = opts || {}
-   doc = Readability::Document.new(open(url).read,
+   doc = Readability::Document.new(URI.open(url, read_timeout: 30, open_timeout: 10).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
🤖 Prompt for AI Agents
In `@app/models/topic_embed.rb` around lines 44 - 53, The import_remote method
uses open(url).read which is unsafe (SSRF, no timeouts/size limits); replace it
with a validated HTTP fetch that only allows http/https, enforces open and read
timeouts, and caps response size before passing content to
Readability::Document; update TopicEmbed.import call to handle fetch errors
(timeout, non-2xx, oversized body) by rescuing and returning/logging a sensible
failure, and reference the import_remote method and Readability::Document when
making these changes.


# 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') %>
<%- end if %>

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Syntax error: end if without a condition.

end if in Ruby requires a trailing boolean expression. As written, end if with no condition will raise a SyntaxError. This should simply be end.

🐛 Proposed fix
-  <%- end if %>
+  <%- end %>
📝 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
<%- end if %>
<%- end %>
🤖 Prompt for AI Agents
In `@app/views/embed/best.html.erb` at line 6, The ERB template contains an
invalid Ruby tag "end if" with no condition; replace the dangling "end if" with
a plain "end" ERB closing tag so the conditional block is properly terminated
(locate the "<%- end if %>" occurrence in the template and change it to a simple
"end" closing tag).


<%= 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 %>'>

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Avatar <img> is missing an alt attribute.

For accessibility, the avatar image should include an alt attribute (e.g., the user's username).

Proposed fix
-        <img src='<%= post.user.small_avatar_url %>'>
+        <img src='<%= post.user.small_avatar_url %>' alt='<%= post.user.username %>'>
📝 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 %>'>
<img src='<%= post.user.small_avatar_url %>' alt='<%= post.user.username %>'>
🤖 Prompt for AI Agents
In `@app/views/embed/best.html.erb` at line 16, The <img> tag rendering the avatar
(src from post.user.small_avatar_url) is missing an alt attribute; update the
tag to include a meaningful, escaped alt value such as the user's username
(e.g., use post.user.username or post.user.name) and provide a sensible fallback
(empty string) to ensure accessibility and avoid nil errors when the username is
missing.

<h3><%= post.user.username %></h3>
</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