mirror of
https://github.com/discourse/discourse.git
synced 2026-08-06 13:08:40 +08:00
Previously, topic OG image generation passed an SVG containing data URI assets to ImageMagick, so the hardened policy denied the ordinary SVG and DATA coders and returned no preview. This change materializes assets in an isolated per-render directory, pre-rasterizes SVG assets, and renders the canvas explicitly through MSVG with the required MVG coder, restoring previews while keeping ordinary SVG, DATA, delegates, filters, and restricted paths denied and omitting invalid assets without failing the full render.
415 lines
13 KiB
Ruby
Vendored
415 lines
13 KiB
Ruby
Vendored
# frozen_string_literal: true
|
|
|
|
class TopicOgImageGenerator
|
|
OG_WIDTH = 1200
|
|
OG_HEIGHT = 630
|
|
MAX_TITLE_LENGTH = 100
|
|
MAX_TITLE_LINES = 2
|
|
TITLE_LINE_CHARS = 34
|
|
LOGO_HEIGHT = 100
|
|
LOGO_WIDTH = 300
|
|
AVATAR_SIZE = 72
|
|
SIDE_MARGIN = 80
|
|
FONT_FAMILY = "sans-serif"
|
|
|
|
# OG images are embedded in public topic pages for external crawlers and
|
|
# link previewers, so we must not generate them for content that is not
|
|
# publicly visible (PMs or topics in read-restricted categories).
|
|
def self.eligible?(topic)
|
|
return false if topic.nil?
|
|
return false if SiteSetting.login_required
|
|
return false if topic.private_message?
|
|
return false if topic.category&.read_restricted?
|
|
true
|
|
end
|
|
|
|
def initialize(topic)
|
|
@topic = topic
|
|
end
|
|
|
|
# Generates the OG image, persists as an Upload, and returns it (or nil on failure).
|
|
def generate
|
|
png = render_png
|
|
return nil if png.nil?
|
|
create_upload(png)
|
|
end
|
|
|
|
# Renders the OG image to PNG bytes without persisting an Upload. Used by the
|
|
# admin preview endpoint to avoid producing orphaned uploads on every click.
|
|
def generate_bytes
|
|
render_png
|
|
end
|
|
|
|
private
|
|
|
|
def build_svg(asset_directory: nil)
|
|
title = truncated_title
|
|
category_name = @topic.category&.name || ""
|
|
category_color = @topic.category&.color || "888888"
|
|
like_count = @topic.like_count || 0
|
|
posts_count = [@topic.posts_count - 1, 0].max
|
|
read_time = calculate_read_time
|
|
colors = fetch_colors
|
|
logo_upload = SiteSetting.logo.presence || SiteSetting.logo_small
|
|
logo_data_uri = fetch_as_data_uri(logo_upload&.url)
|
|
logo_path = materialize_asset(logo_data_uri, directory: asset_directory, basename: "logo")
|
|
|
|
title_lines = word_wrap(title, TITLE_LINE_CHARS)
|
|
truncated = title_lines.length > MAX_TITLE_LINES
|
|
display_lines = title_lines.take(MAX_TITLE_LINES)
|
|
if truncated
|
|
last = display_lines.last
|
|
display_lines[-1] = (
|
|
if last.length > TITLE_LINE_CHARS - 1
|
|
"#{last[0...TITLE_LINE_CHARS - 1]}…"
|
|
else
|
|
"#{last}…"
|
|
end
|
|
)
|
|
end
|
|
|
|
title_start_y = 190
|
|
title_end_y = title_start_y + ((display_lines.length - 1) * 58)
|
|
author_y = title_end_y + 55
|
|
|
|
author = @topic.user
|
|
avatar_data_uri =
|
|
author ? fetch_as_data_uri(author.avatar_template_url.gsub("{size}", "120")) : nil
|
|
avatar_path = materialize_asset(avatar_data_uri, directory: asset_directory, basename: "avatar")
|
|
username = author&.username
|
|
created_at = @topic.created_at&.strftime("%b %-d, %Y")
|
|
|
|
logo_y = OG_HEIGHT - 60 - LOGO_HEIGHT
|
|
stats_y = OG_HEIGHT - 60 - (LOGO_HEIGHT / 2) + 15
|
|
|
|
<<~SVG
|
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="#{OG_WIDTH}" height="#{OG_HEIGHT}" viewBox="0 0 #{OG_WIDTH} #{OG_HEIGHT}">
|
|
<defs>
|
|
<clipPath id="avatar-clip">
|
|
<rect x="#{SIDE_MARGIN}" y="#{author_y}" width="#{AVATAR_SIZE}" height="#{AVATAR_SIZE}" rx="20"/>
|
|
</clipPath>
|
|
</defs>
|
|
|
|
<!-- Background -->
|
|
<rect width="#{OG_WIDTH}" height="#{OG_HEIGHT}" fill="##{colors[:secondary]}"/>
|
|
|
|
<!-- Top accent bar -->
|
|
<rect width="#{OG_WIDTH}" height="18" fill="##{colors[:tertiary]}"/>
|
|
|
|
<!-- Category pill -->
|
|
#{category_pill_svg(category_name, category_color, SIDE_MARGIN, 60)}
|
|
|
|
<!-- Title -->
|
|
#{title_svg(display_lines, colors[:primary], SIDE_MARGIN, title_start_y)}
|
|
|
|
<!-- Author -->
|
|
#{author_svg(avatar_path, username, created_at, colors, SIDE_MARGIN, author_y)}
|
|
|
|
<!-- Site logo -->
|
|
#{logo_svg(logo_path, logo_upload, SIDE_MARGIN, logo_y)}
|
|
|
|
<!-- Stats -->
|
|
#{stats_svg(like_count, posts_count, read_time, colors, OG_WIDTH - SIDE_MARGIN, stats_y)}
|
|
|
|
<!-- Site domain -->
|
|
#{domain_svg(colors, OG_WIDTH - SIDE_MARGIN, 88)}
|
|
</svg>
|
|
SVG
|
|
end
|
|
|
|
# Category pill size. Character-width heuristic is approximate and tuned for a
|
|
# generic sans-serif at font-size 24; long / non-Latin names are truncated
|
|
# here and the rect is capped to the canvas width so we never render past the
|
|
# right edge. Non-Latin glyphs may not fill the pill perfectly but will stay
|
|
# visually contained.
|
|
def category_pill_svg(name, color, x, y)
|
|
return "" if name.blank?
|
|
|
|
pill_padding = 16
|
|
max_pill_width = OG_WIDTH - (2 * SIDE_MARGIN)
|
|
pill_width = [name.length * 14.5 + (pill_padding * 2), max_pill_width].min
|
|
max_name_chars = [((pill_width - (pill_padding * 2)) / 14.5).floor, 1].max
|
|
display_name = name.length > max_name_chars ? "#{name[0...[max_name_chars - 1, 1].max]}…" : name
|
|
|
|
<<~SVG
|
|
<rect x="#{x}" y="#{y}" width="#{pill_width}" height="40" rx="6" fill="##{color}" fill-opacity="0.15"/>
|
|
<text x="#{x + pill_padding}" y="#{y + 30}" font-family="#{FONT_FAMILY}" font-size="24" font-weight="600" fill="##{color}">#{escape_xml(display_name)}</text>
|
|
SVG
|
|
end
|
|
|
|
def title_svg(lines, primary_color, x, start_y)
|
|
lines
|
|
.each_with_index
|
|
.map do |line, i|
|
|
y = start_y + (i * 68)
|
|
%(<text x="#{x}" y="#{y}" font-family="#{FONT_FAMILY}" font-size="62" font-weight="700" fill="##{primary_color}">#{escape_xml(line)}</text>)
|
|
end
|
|
.join("\n ")
|
|
end
|
|
|
|
def author_svg(avatar_data_uri, username, created_at, colors, x, y)
|
|
return "" if username.blank?
|
|
|
|
parts = []
|
|
|
|
parts << <<~SVG.strip if avatar_data_uri.present?
|
|
<image x="#{x}" y="#{y}" width="#{AVATAR_SIZE}" height="#{AVATAR_SIZE}" href="#{avatar_data_uri}" clip-path="url(#avatar-clip)"/>
|
|
<rect x="#{x}" y="#{y}" width="#{AVATAR_SIZE}" height="#{AVATAR_SIZE}" rx="20" fill="none" stroke="##{colors[:secondary]}" stroke-opacity="0.1" stroke-width="1"/>
|
|
SVG
|
|
|
|
text_x = avatar_data_uri.present? ? x + AVATAR_SIZE + 32 : x
|
|
text_y = y + (AVATAR_SIZE / 2) + 8
|
|
|
|
author_text = username
|
|
author_text = "#{author_text} · #{created_at}" if created_at.present?
|
|
|
|
parts << %(<text x="#{text_x}" y="#{text_y}" font-family="#{FONT_FAMILY}" font-size="34" font-weight="400" fill="##{colors[:primary]}" opacity="0.6">#{escape_xml(author_text)}</text>)
|
|
|
|
parts.join("\n ")
|
|
end
|
|
|
|
def logo_svg(logo_data_uri, upload, x, y)
|
|
return "" if logo_data_uri.blank?
|
|
|
|
orig_w = upload&.width.to_f
|
|
orig_h = upload&.height.to_f
|
|
|
|
if orig_w > 0 && orig_h > 0
|
|
scale = [LOGO_WIDTH.to_f / orig_w, LOGO_HEIGHT.to_f / orig_h].min
|
|
w = (orig_w * scale).round
|
|
h = (orig_h * scale).round
|
|
else
|
|
w = LOGO_WIDTH
|
|
h = LOGO_HEIGHT
|
|
end
|
|
|
|
offset_y = y + ((LOGO_HEIGHT - h) / 2)
|
|
|
|
<<~SVG
|
|
<image x="#{x}" y="#{offset_y}" width="#{w}" height="#{h}" href="#{logo_data_uri}"/>
|
|
SVG
|
|
end
|
|
|
|
def stats_svg(likes, replies, read_time, colors, right_x, y)
|
|
items = []
|
|
items << "#{read_time}m read" if read_time && read_time >= 1
|
|
items << "#{replies} #{replies == 1 ? "reply" : "replies"}" if replies > 0
|
|
items << "#{likes} #{likes == 1 ? "like" : "likes"}" if likes > 0
|
|
return "" if items.empty?
|
|
|
|
text = items.join(" · ")
|
|
%(<text x="#{right_x}" y="#{y}" font-family="#{FONT_FAMILY}" font-size="30" font-weight="400" fill="##{colors[:primary]}" opacity="0.5" text-anchor="end">#{escape_xml(text)}</text>)
|
|
end
|
|
|
|
def domain_svg(colors, right_x, y)
|
|
domain = Discourse.current_hostname
|
|
return "" if domain.blank?
|
|
|
|
%(<text x="#{right_x}" y="#{y}" font-family="#{FONT_FAMILY}" font-size="30" font-weight="400" fill="##{colors[:primary]}" opacity="0.5" text-anchor="end">#{escape_xml(domain)}</text>)
|
|
end
|
|
|
|
def calculate_read_time
|
|
return nil if @topic.word_count.to_i <= 0 || SiteSetting.read_time_word_count.to_i <= 0
|
|
|
|
min_post_read_time = 4.0
|
|
[
|
|
@topic.word_count / SiteSetting.read_time_word_count,
|
|
@topic.posts_count * min_post_read_time / 60,
|
|
].max.ceil
|
|
end
|
|
|
|
def truncated_title
|
|
title = @topic.title || ""
|
|
title.length > MAX_TITLE_LENGTH ? "#{title[0...MAX_TITLE_LENGTH - 1]}…" : title
|
|
end
|
|
|
|
# Wraps by whitespace when possible; falls back to grapheme-boundary splits
|
|
# for runs longer than max_chars (e.g. CJK titles with no spaces, long URLs)
|
|
# so the title doesn't overflow the canvas.
|
|
def word_wrap(text, max_chars)
|
|
lines = []
|
|
current_line = +""
|
|
|
|
text.split.each do |word|
|
|
chunks = word.length > max_chars ? split_chunks(word, max_chars) : [word]
|
|
|
|
chunks.each do |chunk|
|
|
if current_line.empty?
|
|
current_line = chunk.dup
|
|
elsif (current_line.length + 1 + chunk.length) <= max_chars
|
|
current_line << " " << chunk
|
|
else
|
|
lines << current_line
|
|
current_line = chunk.dup
|
|
end
|
|
end
|
|
end
|
|
|
|
lines << current_line if current_line.present?
|
|
lines
|
|
end
|
|
|
|
def split_chunks(word, max_chars)
|
|
word.grapheme_clusters.each_slice(max_chars).map(&:join)
|
|
end
|
|
|
|
def fetch_colors
|
|
scheme = Theme.find_by(id: SiteSetting.default_theme_id)&.color_scheme
|
|
if scheme
|
|
resolved = scheme.resolved_colors
|
|
{
|
|
primary: resolved["primary"] || "000000",
|
|
secondary: resolved["secondary"] || "ffffff",
|
|
tertiary: resolved["tertiary"] || "0088cc",
|
|
}
|
|
else
|
|
base = ColorScheme.base_colors
|
|
{
|
|
primary: base["primary"] || "000000",
|
|
secondary: base["secondary"] || "ffffff",
|
|
tertiary: base["tertiary"] || "0088cc",
|
|
}
|
|
end
|
|
end
|
|
|
|
def fetch_as_data_uri(url)
|
|
return nil if url.blank?
|
|
|
|
absolute_url = get_absolute_url(url)
|
|
tmp =
|
|
FileHelper.download(
|
|
absolute_url,
|
|
max_file_size: 1.megabyte,
|
|
tmp_file_name: "og_image_asset",
|
|
follow_redirect: true,
|
|
read_timeout: 10,
|
|
)
|
|
return nil if tmp.nil?
|
|
|
|
path =
|
|
(
|
|
begin
|
|
URI.parse(absolute_url).path
|
|
rescue StandardError
|
|
absolute_url
|
|
end
|
|
)
|
|
content_type = MiniMime.lookup_by_filename(path)&.content_type || "image/png"
|
|
encoded = Base64.strict_encode64(tmp.read)
|
|
"data:#{content_type};base64,#{encoded}"
|
|
rescue => e
|
|
Discourse.warn("Failed to fetch image for OG generation", url: url, error: e.message)
|
|
nil
|
|
ensure
|
|
tmp&.close
|
|
tmp&.unlink if tmp.respond_to?(:unlink)
|
|
end
|
|
|
|
def get_absolute_url(url)
|
|
return url if url.start_with?("http")
|
|
return "https:#{url}" if url.start_with?("//")
|
|
"#{Discourse.base_url_no_prefix}#{url}"
|
|
end
|
|
|
|
def materialize_asset(data_uri, directory:, basename:)
|
|
return data_uri if directory.nil? || data_uri.blank?
|
|
|
|
match = data_uri.match(%r{\Adata:(image/[a-z0-9.+-]+);base64,(.+)\z}m)
|
|
return nil if match.nil?
|
|
|
|
content_type = match[1]
|
|
bytes = Base64.strict_decode64(match[2])
|
|
|
|
if content_type == "image/svg+xml"
|
|
svg_path = File.join(directory, "#{basename}.svg")
|
|
png_path = File.join(directory, "#{basename}.png")
|
|
File.binwrite(svg_path, bytes)
|
|
ImageMagick.magick(
|
|
"MSVG:#{svg_path}",
|
|
png_path,
|
|
read: [svg_path],
|
|
write: [directory],
|
|
timeout: 10,
|
|
)
|
|
return png_path if File.exist?(png_path)
|
|
|
|
return nil
|
|
end
|
|
|
|
extension =
|
|
{ "image/gif" => "gif", "image/jpeg" => "jpg", "image/png" => "png", "image/webp" => "webp" }[
|
|
content_type
|
|
]
|
|
return nil if extension.nil?
|
|
|
|
path = File.join(directory, "#{basename}.#{extension}")
|
|
File.binwrite(path, bytes)
|
|
path
|
|
rescue ArgumentError, Discourse::Utils::CommandError => error
|
|
Discourse.warn(
|
|
"Failed to materialize topic OG image asset",
|
|
topic_id: @topic.id,
|
|
asset: basename,
|
|
error: error.message,
|
|
)
|
|
nil
|
|
end
|
|
|
|
def escape_xml(text)
|
|
text
|
|
.to_s
|
|
.gsub("&", "&")
|
|
.gsub("<", "<")
|
|
.gsub(">", ">")
|
|
.gsub("'", "'")
|
|
.gsub('"', """)
|
|
end
|
|
|
|
def render_png
|
|
Dir.mktmpdir("topic_og") do |dir|
|
|
svg_path = File.join(dir, "og.svg")
|
|
png_path = File.join(dir, "og.png")
|
|
|
|
File.write(svg_path, build_svg(asset_directory: dir))
|
|
|
|
ImageMagick.magick(
|
|
"-background",
|
|
"none",
|
|
"-size",
|
|
"#{OG_WIDTH}x#{OG_HEIGHT}",
|
|
"MSVG:#{svg_path}",
|
|
"-depth",
|
|
"8",
|
|
"-define",
|
|
"png:compression-level=9",
|
|
png_path,
|
|
read: [dir],
|
|
write: [dir],
|
|
nice: 10,
|
|
timeout: 20,
|
|
)
|
|
|
|
return nil unless File.exist?(png_path)
|
|
FileHelper.optimize_image!(png_path)
|
|
File.binread(png_path)
|
|
end
|
|
rescue Discourse::Utils::CommandError => e
|
|
Discourse.warn("Failed to render topic OG image", topic_id: @topic.id, error: e.message)
|
|
nil
|
|
end
|
|
|
|
def create_upload(png_bytes)
|
|
Tempfile.create(["topic-og-#{@topic.id}", ".png"], binmode: true) do |tmp|
|
|
tmp.write(png_bytes)
|
|
tmp.rewind
|
|
return(
|
|
UploadCreator.new(
|
|
tmp,
|
|
"topic-og-#{@topic.id}.png",
|
|
type: "topic_og_image",
|
|
skip_validations: true,
|
|
).create_for(Discourse.system_user.id)
|
|
)
|
|
end
|
|
end
|
|
end
|