mirror of
https://github.com/discourse/discourse.git
synced 2026-08-04 10:39:43 +08:00
SVG and compressed raster assets can declare dimensions that require excessive memory before the renderer produces the fixed-size topic OG canvas. This commit applies the former ImageMagick 64K dimension ceiling and the configured image megapixel ceiling before libvips decodes an OG asset.
439 lines
14 KiB
Ruby
Vendored
439 lines
14 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"
|
|
MAX_ASSET_DIMENSION = 64 * 1024
|
|
private_constant :MAX_ASSET_DIMENSION
|
|
|
|
# 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])
|
|
format =
|
|
{
|
|
"image/gif" => %w[gif gifload],
|
|
"image/jpeg" => %w[jpeg jpegload],
|
|
"image/png" => %w[png pngload],
|
|
"image/svg+xml" => %w[svg svgload],
|
|
"image/webp" => %w[webp webpload],
|
|
}[
|
|
content_type
|
|
]
|
|
return nil if format.nil?
|
|
|
|
extension, loader = format
|
|
source = File.join(directory, "#{basename}.source.#{extension}")
|
|
output = File.join(directory, "#{basename}.png")
|
|
File.binwrite(source, bytes)
|
|
if extension == "svg"
|
|
document = Nokogiri.XML(bytes) { |config| config.strict.nonet }
|
|
return nil if document.root&.name != "svg"
|
|
if document.xpath(
|
|
"//*[local-name()='filter' or starts-with(local-name(),'fe') or local-name()='image' or local-name()='style']",
|
|
).present?
|
|
return nil
|
|
end
|
|
return nil if document.xpath("//@*[local-name()='filter']").present?
|
|
|
|
references = document.xpath("//@*[local-name()='href']").map(&:value)
|
|
return nil if references.any? { |reference| !reference.start_with?("#") }
|
|
|
|
styles = document.xpath("//@*[local-name()='style']").map(&:value)
|
|
return nil if styles.any? { |style| style.match?(/filter\s*:|url\(\s*(?!['\"]?#)/i) }
|
|
elsif FastImage.type(source).to_s != extension
|
|
return nil
|
|
end
|
|
|
|
dimensions = FastImage.size(source)
|
|
return nil if dimensions.nil?
|
|
return nil if dimensions.any? { |dimension| dimension <= 0 || dimension > MAX_ASSET_DIMENSION }
|
|
|
|
max_pixels = SiteSetting.max_image_megapixels.to_i * 1_000_000
|
|
return nil if max_pixels.positive? && dimensions.inject(:*) >= max_pixels
|
|
|
|
Vips.run(
|
|
"vips",
|
|
loader,
|
|
source,
|
|
"#{output}[compression=9]",
|
|
read: [source],
|
|
write: [directory],
|
|
timeout: 1,
|
|
allow_untrusted: loader == "svgload",
|
|
)
|
|
File.exist?(output) ? output : nil
|
|
rescue ArgumentError, Discourse::Utils::CommandError, Nokogiri::XML::SyntaxError => 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))
|
|
|
|
Vips.run(
|
|
"vips",
|
|
"svgload",
|
|
svg_path,
|
|
"#{png_path}[compression=9]",
|
|
read: [dir],
|
|
write: [dir],
|
|
nice: 10,
|
|
timeout: 20,
|
|
allow_untrusted: true,
|
|
)
|
|
|
|
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
|