0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-05 11:52:32 +08:00
discourse/lib/letter_avatar.rb
Alan Guo Xiang Tan a8e4746b51
FEATURE: Use libvips for low-risk image operations
Letter avatars, dominant-color extraction, and generated topic OG images use ImageMagick today. These straightforward operations keep ImageMagick in the request and background-job paths while the wider image-processing migration remains behind a site setting.

This commit moves the three operations to the stock libvips CLI. It permits untrusted libvips access only for explicit SVG loads, converts each OG asset through its expected loader, rejects unsafe SVG features and media-type mismatches, and bounds SVG asset rendering. Dominant-color extraction verifies the stored format before decoding and caches unsupported pixel layouts as an empty result.

Failures do not fall back to ImageMagick. The companion discourse_docker change removes the unsupported TIFF loader from the libvips build.
2026-08-04 06:19:27 +08:00

419 lines
9.5 KiB
Ruby
Vendored

# frozen_string_literal: true
require "erb"
class LetterAvatar
class Identity
attr_accessor :color, :letter
def self.from_username(username)
identity = new
identity.color =
LetterAvatar::COLORS[
Digest::MD5.hexdigest(username)[0...15].to_i(16) % LetterAvatar::COLORS.length
]
identity.letter = username[0].upcase
identity
end
end
# BUMP UP if avatar algorithm changes
VERSION = 6
# CHANGE these values to support more pixel ratios
FULLSIZE = 120 * 3
POINTSIZE = 280
SVG_TEXT_BASELINE = 245
private_constant :SVG_TEXT_BASELINE
FONT_LIST_TIMEOUT_SECONDS = 10
private_constant :FONT_LIST_TIMEOUT_SECONDS
class << self
def version
"#{VERSION}_#{vips_version}"
end
def cache_path
"tmp/letter_avatars/#{version}"
end
# Run `script/letter_avatar_pixel_diff` to inspect rendering changes.
def generate(username, size, opts = nil)
DistributedMutex.synchronize("letter_avatar_#{version}_#{username}") do
identity = (opts && opts[:identity]) || LetterAvatar::Identity.from_username(username)
cache = true
cache = false if opts && opts[:cache] == false
size = FULLSIZE if size > FULLSIZE
filename = cached_path(identity, size)
return filename if cache && File.exist?(filename)
fullsize = fullsize_path(identity)
generate_fullsize(identity) if !cache || !File.exist?(fullsize)
resize(fullsize, filename, size)
filename
end
end
def cached_path(identity, size)
dir = "#{cache_path}/#{identity.letter}/#{identity.color.join("_")}"
FileUtils.mkdir_p(dir)
File.expand_path "#{dir}/#{size}.png"
end
def fullsize_path(identity)
File.expand_path cached_path(identity, FULLSIZE)
end
def generate_fullsize(identity)
filename = fullsize_path(identity)
Tempfile.create(%w[letter-avatar .svg]) do |svg|
svg.write(svg_for(identity))
svg.flush
Vips.run(
"vips",
"svgload",
svg.path,
filename,
read: [svg.path],
write: [File.dirname(filename)],
allow_untrusted: true,
)
end
filename
end
def vips_version
return @vips_version if @vips_version
fonts =
Discourse::Utils
.execute_command(
"fc-list",
"--format",
"%{file}\t%{family}\t%{style}\t%{fontversion}\n",
timeout: FONT_LIST_TIMEOUT_SECONDS,
)
.lines
.sort
.join
@vips_version =
Digest::MD5.hexdigest([VERSION.to_s, Vips.run("vips", "--version"), fonts].join("\0"))
Thread.new do
sleep 2
cleanup_old
end
@vips_version
end
def cleanup_old
skip = File.basename(cache_path)
parent_path = File.dirname(cache_path)
Dir
.entries(parent_path)
.each do |path|
FileUtils.rm_rf(parent_path + "/" + path) unless %w[. ..].include?(path) || path == skip
end
rescue Errno::ENOENT
# no worries, folder doesn't exists
end
private
def resize(from, to, size)
profile = Rails.root.join("vendor/data/RT_sRGB.icm").to_s
output = "#{to}[palette,Q=100,compression=6,strip]"
Tempfile.create(%w[letter-avatar-resized .png], binmode: true) do |resized|
resized.close
Vips.run(
"vips",
"thumbnail",
from,
"#{resized.path}[compression=6,strip]",
size.to_s,
"--height",
size.to_s,
"--size",
"both",
"--crop",
"centre",
"--output-profile",
profile,
read: [from, profile],
write: [resized.path],
nice: 10,
)
Vips.run(
"vips",
"sharpen",
resized.path,
output,
"--sigma",
"0.5",
"--m1",
"0.7",
read: [resized.path],
write: [File.dirname(to)],
nice: 10,
)
end
end
def svg_for(identity)
color = identity.color.join(",")
letter = ERB::Util.html_escape(identity.letter)
baseline = SVG_TEXT_BASELINE + vertical_offset
<<~SVG
<svg xmlns="http://www.w3.org/2000/svg" width="#{FULLSIZE}" height="#{FULLSIZE}" viewBox="0 0 #{FULLSIZE} #{FULLSIZE}">
<rect width="#{FULLSIZE}" height="#{FULLSIZE}" fill="rgb(#{color})"/>
<text x="#{FULLSIZE / 2}" y="#{baseline}" fill="white" fill-opacity="0.8" font-family="#{font_family}" font-size="#{POINTSIZE}" text-anchor="middle" dominant-baseline="central">#{letter}</text>
</svg>
SVG
end
# Use Helvetica on macOS because Nimbus Sans is unavailable there.
def font_family
RbConfig::CONFIG["host_os"].match?(/darwin/i) ? "Helvetica" : "Nimbus Sans"
end
# Helvetica needs a larger offset to keep the glyph vertically centered.
def vertical_offset
font_family == "Helvetica" ? 26 : 34
end
end
# palette of optimally distinct colors
# cf. http://tools.medialab.sciences-po.fr/iwanthue/index.php
# parameters used:
# - H: 0 - 360
# - C: 0 - 2
# - L: 0.75 - 1.5
COLORS = [
[198, 125, 40],
[61, 155, 243],
[74, 243, 75],
[238, 89, 166],
[52, 240, 224],
[177, 156, 155],
[240, 120, 145],
[111, 154, 78],
[237, 179, 245],
[237, 101, 95],
[89, 239, 155],
[43, 254, 70],
[163, 212, 245],
[65, 152, 142],
[165, 135, 246],
[181, 166, 38],
[187, 229, 206],
[77, 164, 25],
[179, 246, 101],
[234, 93, 37],
[225, 155, 115],
[142, 140, 188],
[223, 120, 140],
[249, 174, 27],
[244, 117, 225],
[137, 141, 102],
[75, 191, 146],
[188, 239, 142],
[164, 199, 145],
[173, 120, 149],
[59, 195, 89],
[222, 198, 220],
[68, 145, 187],
[236, 204, 179],
[159, 195, 72],
[188, 121, 189],
[166, 160, 85],
[181, 233, 37],
[236, 177, 85],
[121, 147, 160],
[234, 218, 110],
[241, 157, 191],
[62, 200, 234],
[133, 243, 34],
[88, 149, 110],
[59, 228, 248],
[183, 119, 118],
[251, 195, 45],
[113, 196, 122],
[197, 115, 70],
[80, 175, 187],
[103, 231, 238],
[240, 72, 133],
[228, 149, 241],
[180, 188, 159],
[172, 132, 85],
[180, 135, 251],
[236, 194, 58],
[217, 176, 109],
[88, 244, 199],
[186, 157, 239],
[113, 230, 96],
[206, 115, 165],
[244, 178, 163],
[230, 139, 26],
[241, 125, 89],
[83, 160, 66],
[107, 190, 166],
[197, 161, 210],
[198, 203, 245],
[238, 117, 19],
[228, 119, 116],
[131, 156, 41],
[145, 178, 168],
[139, 170, 220],
[233, 95, 125],
[87, 178, 230],
[157, 200, 119],
[237, 140, 76],
[229, 185, 186],
[144, 206, 212],
[236, 209, 158],
[185, 189, 79],
[34, 208, 66],
[84, 238, 129],
[133, 140, 134],
[67, 157, 94],
[168, 179, 25],
[140, 145, 240],
[151, 241, 125],
[67, 162, 107],
[200, 156, 21],
[169, 173, 189],
[226, 116, 189],
[133, 231, 191],
[194, 161, 63],
[241, 77, 99],
[241, 217, 53],
[123, 204, 105],
[210, 201, 119],
[229, 108, 155],
[240, 91, 72],
[187, 115, 210],
[240, 163, 100],
[178, 217, 57],
[179, 135, 116],
[204, 211, 24],
[186, 135, 57],
[223, 176, 135],
[204, 148, 151],
[116, 223, 50],
[95, 195, 46],
[123, 160, 236],
[181, 172, 131],
[142, 220, 202],
[240, 140, 112],
[172, 145, 164],
[228, 124, 45],
[135, 151, 243],
[42, 205, 125],
[192, 233, 116],
[119, 170, 114],
[158, 138, 26],
[73, 190, 183],
[185, 229, 243],
[227, 107, 55],
[196, 205, 202],
[132, 143, 60],
[233, 192, 237],
[62, 150, 220],
[205, 201, 141],
[106, 140, 190],
[161, 131, 205],
[135, 134, 158],
[198, 139, 81],
[115, 171, 32],
[101, 181, 67],
[149, 137, 119],
[37, 142, 183],
[183, 130, 175],
[168, 125, 133],
[124, 142, 87],
[236, 156, 171],
[232, 194, 91],
[219, 200, 69],
[144, 219, 34],
[219, 95, 187],
[145, 154, 217],
[165, 185, 100],
[127, 238, 163],
[224, 178, 198],
[119, 153, 120],
[124, 212, 92],
[172, 161, 105],
[231, 155, 135],
[157, 132, 101],
[122, 185, 146],
[53, 166, 51],
[70, 163, 90],
[150, 190, 213],
[210, 107, 60],
[166, 152, 185],
[159, 194, 159],
[39, 141, 222],
[202, 176, 161],
[95, 140, 229],
[168, 142, 87],
[93, 170, 203],
[159, 142, 54],
[14, 168, 39],
[94, 150, 149],
[187, 206, 136],
[157, 224, 166],
[235, 158, 208],
[109, 232, 216],
[141, 201, 87],
[208, 124, 118],
[142, 125, 214],
[19, 237, 174],
[72, 219, 41],
[234, 102, 111],
[168, 142, 79],
[188, 135, 35],
[95, 155, 143],
[148, 173, 116],
[223, 112, 95],
[228, 128, 236],
[206, 114, 54],
[195, 119, 88],
[235, 140, 94],
[235, 202, 125],
[233, 155, 153],
[214, 214, 238],
[246, 200, 35],
[151, 125, 171],
[132, 145, 172],
[131, 142, 118],
[199, 126, 150],
[61, 162, 123],
[58, 176, 151],
[215, 141, 69],
[225, 154, 220],
[220, 77, 167],
[233, 161, 64],
[130, 221, 137],
[81, 191, 129],
[169, 162, 140],
[174, 177, 222],
[236, 174, 47],
[233, 188, 180],
[69, 222, 172],
[71, 232, 93],
[118, 211, 238],
[157, 224, 83],
[218, 105, 73],
[126, 169, 36],
]
end