mirror of
https://github.com/discourse/discourse.git
synced 2025-01-31 00:08:30 +08:00
badd64ceee
When uploads are stored on S3, by default Discourse will fetch the avatars and proxy them through to the requesting client. This is simple, but it can lead to significant inbound/outbound network load in the hosting environment. This commit adds an optional redirect_avatar_requests GlobalSetting. When enabled, requests for user avatars will be redirected to the S3 asset instead of being proxied. This adds an extra round-trip for clients, but it should significantly reduce server load. To mitigate that extra round-trip for clients, a CDN with 'follow redirect' capability could be used.
211 lines
6.0 KiB
Ruby
211 lines
6.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class UserAvatarsController < ApplicationController
|
|
|
|
skip_before_action :preload_json, :redirect_to_login_if_required, :check_xhr, :verify_authenticity_token, only: [:show, :show_letter, :show_proxy_letter]
|
|
|
|
before_action :apply_cdn_headers, only: [:show, :show_letter, :show_proxy_letter]
|
|
|
|
def refresh_gravatar
|
|
user = User.find_by(username_lower: params[:username].downcase)
|
|
guardian.ensure_can_edit!(user)
|
|
|
|
if user
|
|
hijack do
|
|
user.create_user_avatar(user_id: user.id) unless user.user_avatar
|
|
user.user_avatar.update_gravatar!
|
|
|
|
gravatar = if user.user_avatar.gravatar_upload_id
|
|
{
|
|
gravatar_upload_id: user.user_avatar.gravatar_upload_id,
|
|
gravatar_avatar_template: User.avatar_template(user.username, user.user_avatar.gravatar_upload_id)
|
|
}
|
|
else
|
|
{
|
|
gravatar_upload_id: nil,
|
|
gravatar_avatar_template: nil
|
|
}
|
|
end
|
|
|
|
render json: gravatar
|
|
end
|
|
else
|
|
raise Discourse::NotFound
|
|
end
|
|
end
|
|
|
|
def show_proxy_letter
|
|
is_asset_path
|
|
|
|
if SiteSetting.external_system_avatars_url !~ /^\/letter_avatar_proxy/
|
|
raise Discourse::NotFound
|
|
end
|
|
|
|
params.require(:letter)
|
|
params.require(:color)
|
|
params.require(:version)
|
|
params.require(:size)
|
|
|
|
hijack do
|
|
begin
|
|
proxy_avatar("https://avatars.discourse-cdn.com/#{params[:version]}/letter/#{params[:letter]}/#{params[:color]}/#{params[:size]}.png", Time.new('1990-01-01'))
|
|
rescue OpenURI::HTTPError
|
|
render_blank
|
|
end
|
|
end
|
|
end
|
|
|
|
def show_letter
|
|
is_asset_path
|
|
|
|
params.require(:username)
|
|
params.require(:version)
|
|
params.require(:size)
|
|
|
|
no_cookies
|
|
|
|
return render_blank if params[:version] != LetterAvatar.version
|
|
|
|
hijack do
|
|
image = LetterAvatar.generate(params[:username].to_s, params[:size].to_i)
|
|
|
|
response.headers["Last-Modified"] = File.ctime(image).httpdate
|
|
response.headers["Content-Length"] = File.size(image).to_s
|
|
immutable_for(1.year)
|
|
send_file image, disposition: nil
|
|
end
|
|
end
|
|
|
|
def show
|
|
is_asset_path
|
|
|
|
# we need multisite support to keep a single origin pull for CDNs
|
|
RailsMultisite::ConnectionManagement.with_hostname(params[:hostname]) do
|
|
hijack do
|
|
show_in_site(params[:hostname])
|
|
end
|
|
end
|
|
end
|
|
|
|
protected
|
|
|
|
def show_in_site(hostname)
|
|
|
|
username = params[:username].to_s
|
|
return render_blank unless user = User.find_by(username_lower: username.downcase)
|
|
|
|
upload_id, version = params[:version].split("_")
|
|
|
|
version = (version || OptimizedImage::VERSION).to_i
|
|
|
|
# old versions simply get new avatar
|
|
if version > OptimizedImage::VERSION
|
|
return render_blank
|
|
end
|
|
|
|
upload_id = upload_id.to_i
|
|
return render_blank unless upload_id > 0
|
|
|
|
size = params[:size].to_i
|
|
return render_blank if size < 8 || size > 1000
|
|
|
|
if !Discourse.avatar_sizes.include?(size) && Discourse.store.external?
|
|
closest = Discourse.avatar_sizes.to_a.min { |a, b| (size - a).abs <=> (size - b).abs }
|
|
avatar_url = UserAvatar.local_avatar_url(hostname, user.encoded_username(lower: true), upload_id, closest)
|
|
return redirect_to cdn_path(avatar_url), allow_other_host: true
|
|
end
|
|
|
|
upload = Upload.find_by(id: upload_id) if user&.user_avatar&.contains_upload?(upload_id)
|
|
upload ||= user.uploaded_avatar if user.uploaded_avatar_id == upload_id
|
|
|
|
if user.uploaded_avatar && !upload
|
|
avatar_url = UserAvatar.local_avatar_url(hostname, user.encoded_username(lower: true), user.uploaded_avatar_id, size)
|
|
return redirect_to cdn_path(avatar_url), allow_other_host: true
|
|
elsif upload && optimized = get_optimized_image(upload, size)
|
|
if optimized.local?
|
|
optimized_path = Discourse.store.path_for(optimized)
|
|
image = optimized_path if File.exist?(optimized_path)
|
|
elsif GlobalSetting.redirect_avatar_requests
|
|
return redirect_s3_avatar(Discourse.store.cdn_url(optimized.url))
|
|
else
|
|
return proxy_avatar(Discourse.store.cdn_url(optimized.url), upload.created_at)
|
|
end
|
|
end
|
|
|
|
if image
|
|
response.headers["Last-Modified"] = File.ctime(image).httpdate
|
|
response.headers["Content-Length"] = File.size(image).to_s
|
|
immutable_for 1.year
|
|
send_file image, disposition: nil
|
|
else
|
|
render_blank
|
|
end
|
|
rescue OpenURI::HTTPError
|
|
render_blank
|
|
end
|
|
|
|
# Allow plugins to overwrite max file size value
|
|
def max_file_size
|
|
1.megabyte
|
|
end
|
|
|
|
PROXY_PATH = Rails.root + "tmp/avatar_proxy"
|
|
def proxy_avatar(url, last_modified)
|
|
|
|
if url[0..1] == "//"
|
|
url = (SiteSetting.force_https ? "https:" : "http:") + url
|
|
end
|
|
|
|
sha = Digest::SHA1.hexdigest(url)
|
|
filename = "#{sha}#{File.extname(url)}"
|
|
path = "#{PROXY_PATH}/#{filename}"
|
|
|
|
unless File.exist? path
|
|
FileUtils.mkdir_p PROXY_PATH
|
|
tmp = FileHelper.download(
|
|
url,
|
|
max_file_size: max_file_size,
|
|
tmp_file_name: filename,
|
|
follow_redirect: true,
|
|
read_timeout: 10
|
|
)
|
|
|
|
return render_blank if tmp.nil?
|
|
|
|
FileUtils.mv tmp.path, path
|
|
end
|
|
|
|
response.headers["Last-Modified"] = last_modified.httpdate
|
|
response.headers["Content-Length"] = File.size(path).to_s
|
|
immutable_for(1.year)
|
|
send_file path, disposition: nil
|
|
end
|
|
|
|
def redirect_s3_avatar(url)
|
|
immutable_for 1.hour
|
|
redirect_to url, allow_other_host: true
|
|
end
|
|
|
|
# this protects us from a DoS
|
|
def render_blank
|
|
path = Rails.root + "public/images/avatar.png"
|
|
expires_in 10.minutes, public: true
|
|
response.headers["Last-Modified"] = Time.new('1990-01-01').httpdate
|
|
response.headers["Content-Length"] = File.size(path).to_s
|
|
send_file path, disposition: nil
|
|
end
|
|
|
|
protected
|
|
|
|
# consider removal of hacks some time in 2019
|
|
|
|
def get_optimized_image(upload, size)
|
|
return if !upload
|
|
return upload if upload.extension == "svg"
|
|
|
|
upload.get_optimized_image(size, size)
|
|
# TODO decide if we want to detach here
|
|
end
|
|
|
|
end
|