mirror of
https://github.com/discourse/discourse.git
synced 2024-11-24 07:29:22 +08:00
c2332d7505
* 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>
221 lines
6.9 KiB
Ruby
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)
|
|
#
|