discourse/app/models/user_avatar.rb
Sam c2332d7505
FEATURE: reduce avatar sizes to 6 from 20 (#21319)
* FEATURE: reduce avatar sizes to 6 from 20

This PR introduces 3 changes:

1. SiteSetting.avatar_sizes, now does what is says on the tin.
previously it would introduce a large number of extra sizes, to allow for
various DPIs. Instead we now trust the admin with the size list.

2. When `avatar_sizes` changes, we ensure consistency and remove resized
avatars that are not longer allowed per site setting. This happens on the
12 hourly job and limited out of the box to 20k cleanups per cycle, given
this may reach out to AWS 20k times to remove things.

3.Our default avatar sizes are now "24|48|72|96|144|288" these sizes were
very specifically picked to limit amount of bluriness introduced by webkit.
Our avatars are already blurry due to 1px border, so this corrects old blur.

This change heavily reduces storage required by forums which simplifies
site moves and more.

Co-authored-by: David Taylor <david@taylorhq.com>
2023-06-01 10:00:01 +10:00

221 lines
6.9 KiB
Ruby

# frozen_string_literal: true
class UserAvatar < ActiveRecord::Base
belongs_to :user
belongs_to :gravatar_upload, class_name: "Upload"
belongs_to :custom_upload, class_name: "Upload"
has_many :upload_references, as: :target, dependent: :destroy
after_save do
if saved_change_to_custom_upload_id? || saved_change_to_gravatar_upload_id?
upload_ids = [self.custom_upload_id, self.gravatar_upload_id]
UploadReference.ensure_exist!(upload_ids: upload_ids, target: self)
end
end
@@custom_user_gravatar_email_hash = {
Discourse::SYSTEM_USER_ID => User.email_hash("info@discourse.org"),
}
def self.register_custom_user_gravatar_email_hash(user_id, email)
@@custom_user_gravatar_email_hash[user_id] = User.email_hash(email)
end
def contains_upload?(id)
gravatar_upload_id == id || custom_upload_id == id
end
def update_gravatar!
DistributedMutex.synchronize("update_gravatar_#{user_id}") do
begin
self.update!(last_gravatar_download_attempt: Time.zone.now)
max = Discourse.avatar_sizes.max
# The user could be deleted before this executes
return if user.blank? || user.primary_email.blank?
email_hash = @@custom_user_gravatar_email_hash[user_id] || user.email_hash
gravatar_url =
"https://#{SiteSetting.gravatar_base_url}/avatar/#{email_hash}.png?s=#{max}&d=404&reset_cache=#{SecureRandom.urlsafe_base64(5)}"
if SiteSetting.verbose_upload_logging
Rails.logger.warn("Verbose Upload Logging: Downloading gravatar from #{gravatar_url}")
end
# follow redirects in case gravatar change rules on us
tempfile =
FileHelper.download(
gravatar_url,
max_file_size: SiteSetting.max_image_size_kb.kilobytes,
tmp_file_name: "gravatar",
skip_rate_limit: true,
verbose: false,
follow_redirect: true,
)
if tempfile
ext = File.extname(tempfile)
ext = ".png" if ext.blank?
upload =
UploadCreator.new(
tempfile,
"gravatar#{ext}",
origin: gravatar_url,
type: "avatar",
for_gravatar: true,
).create_for(user_id)
if gravatar_upload_id != upload.id
User.transaction do
if gravatar_upload_id && user.uploaded_avatar_id == gravatar_upload_id
user.update!(uploaded_avatar_id: upload.id)
end
self.update!(gravatar_upload: upload)
end
end
end
rescue OpenURI::HTTPError => e
raise e if e.io&.status[0].to_i != 404
ensure
tempfile&.close!
end
end
end
def self.local_avatar_url(hostname, username, upload_id, size)
self.local_avatar_template(hostname, username, upload_id).gsub("{size}", size.to_s)
end
def self.local_avatar_template(hostname, username, upload_id)
version = self.version(upload_id)
"#{Discourse.base_path}/user_avatar/#{hostname}/#{username}/{size}/#{version}.png"
end
def self.external_avatar_url(user_id, upload_id, size)
self.external_avatar_template(user_id, upload_id).gsub("{size}", size.to_s)
end
def self.external_avatar_template(user_id, upload_id)
version = self.version(upload_id)
"#{Discourse.store.absolute_base_url}/avatars/#{user_id}/{size}/#{version}.png"
end
def self.version(upload_id)
"#{upload_id}_#{OptimizedImage::VERSION}"
end
def self.import_url_for_user(avatar_url, user, options = nil)
if SiteSetting.verbose_upload_logging
Rails.logger.warn("Verbose Upload Logging: Downloading sso-avatar from #{avatar_url}")
end
tempfile =
FileHelper.download(
avatar_url,
max_file_size: SiteSetting.max_image_size_kb.kilobytes,
tmp_file_name: "sso-avatar",
follow_redirect: true,
)
return unless tempfile
ext = FastImage.type(tempfile).to_s
tempfile.rewind
upload =
UploadCreator.new(
tempfile,
"external-avatar." + ext,
origin: avatar_url,
type: "avatar",
).create_for(user.id)
user.create_user_avatar! unless user.user_avatar
if !user.user_avatar.contains_upload?(upload.id)
user.user_avatar.update!(custom_upload_id: upload.id)
override_gravatar = !options || options[:override_gravatar]
if user.uploaded_avatar_id.nil? ||
!user.user_avatar.contains_upload?(user.uploaded_avatar_id) || override_gravatar
user.update!(uploaded_avatar_id: upload.id)
end
end
rescue Net::ReadTimeout, OpenURI::HTTPError, FinalDestination::SSRFError
# Skip saving. We are not connected to the net, or SSRF checks failed.
ensure
tempfile.close! if tempfile && tempfile.respond_to?(:close!)
end
def self.ensure_consistency!(max_optimized_avatars_to_remove: 20_000)
DB.exec <<~SQL
UPDATE user_avatars
SET gravatar_upload_id = NULL
WHERE gravatar_upload_id IN (
SELECT u1.gravatar_upload_id FROM user_avatars u1
LEFT JOIN uploads up
ON u1.gravatar_upload_id = up.id
WHERE u1.gravatar_upload_id IS NOT NULL AND
up.id IS NULL
)
SQL
DB.exec <<~SQL
UPDATE user_avatars
SET custom_upload_id = NULL
WHERE custom_upload_id IN (
SELECT u1.custom_upload_id FROM user_avatars u1
LEFT JOIN uploads up
ON u1.custom_upload_id = up.id
WHERE u1.custom_upload_id IS NOT NULL AND
up.id IS NULL
)
SQL
ids =
DB.query_single(<<~SQL, sizes: Discourse.avatar_sizes, limit: max_optimized_avatars_to_remove)
SELECT oi.id FROM user_avatars a
JOIN optimized_images oi ON oi.upload_id = a.custom_upload_id
LEFT JOIN upload_references ur ON ur.upload_id = a.custom_upload_id and ur.target_type <> 'UserAvatar'
WHERE oi.width not in (:sizes) AND oi.height not in (:sizes) AND ur.upload_id IS NULL
LIMIT :limit
SQL
warnings_reported = 0
ids.each do |id|
begin
OptimizedImage.find(id).destroy!
rescue ActiveRecord::RecordNotFound
rescue => e
if warnings_reported < 10
Discourse.warn_exception(e, message: "Failed to remove optimized image")
warnings_reported += 1
end
end
end
end
end
# == Schema Information
#
# Table name: user_avatars
#
# id :integer not null, primary key
# user_id :integer not null
# custom_upload_id :integer
# gravatar_upload_id :integer
# last_gravatar_download_attempt :datetime
# created_at :datetime not null
# updated_at :datetime not null
#
# Indexes
#
# index_user_avatars_on_custom_upload_id (custom_upload_id)
# index_user_avatars_on_gravatar_upload_id (gravatar_upload_id)
# index_user_avatars_on_user_id (user_id)
#