mirror of
https://github.com/discourse/discourse.git
synced 2025-02-23 11:54:06 +08:00

Previously, a user avatar redirect had a lifetime of 24h. That means that a change to the S3 CDN URL would take up to 24h to propagate to clients and intermediate CDNs. This commit reduces the max age to 1 hour, but also introduces a `stale-while-revalidate` directive. This allows clients and CDNs to use a 'stale' value if it was received between 1h and 24h ago, as long as they make a background request to update the cache. This should reduce the impact of S3 URL changes. 1 hour after the change, the CDN will start serving updated values. Plus, if users have cached bad responses, their browser will automatically fetch the correct version and use it on the next page load.
222 lines
6.3 KiB
Ruby
222 lines
6.3 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: %i[show show_letter show_proxy_letter]
|
|
|
|
before_action :apply_cdn_headers, only: %i[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 !~ %r{\A/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 { show_in_site(params[:hostname]) }
|
|
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
|
|
return render_blank if version > OptimizedImage::VERSION
|
|
|
|
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)
|
|
url = (SiteSetting.force_https ? "https:" : "http:") + url if url[0..1] == "//"
|
|
|
|
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)
|
|
response.cache_control[:max_age] = 1.hour.to_i
|
|
response.cache_control[:public] = true
|
|
response.cache_control[:extras] = ["immutable", "stale-while-revalidate=#{1.day.to_i}"]
|
|
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
|