Skip to content

Commit b8d842c

Browse files
authored
Proxy user avatar images from gravatar (#4599)
1 parent bf01bfb commit b8d842c

File tree

9 files changed

+168
-33
lines changed

9 files changed

+168
-33
lines changed
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
class AvatarsController < ApplicationController
2+
before_action :find_user
3+
before_action :set_size
4+
before_action :set_theme
5+
6+
def show
7+
gravatar_url = @user.gravatar_url(size: @size, default: "404", secure: true)
8+
9+
resp = gravatar_client
10+
.get(gravatar_url, nil,
11+
{ "Accept" => "image/png", "Connection" => "close", "User-Agent" => "RubyGems.org avatar proxy" })
12+
13+
if resp.success?
14+
fastly_expires_in(5.minutes)
15+
16+
# don't copy other headers, since they might leak user info
17+
response.headers["last-modified"] = resp.headers["last-modified"] if resp.headers["last-modified"]
18+
filename = "#{@user.display_id}_avatar_#{@size}.#{params[:format]}"
19+
send_data(resp.body, type: resp.headers["content-type"], disposition: "inline", filename:)
20+
elsif resp.status == 404
21+
fastly_expires_in(5.minutes)
22+
23+
# means gravatar doesn't have an avatar for this user
24+
# we'll just redirect to our default avatar instead, so everything is cachable
25+
redirect_to default_avatar_url
26+
else
27+
# any other error, just redirect to our default avatar
28+
# this includes 400, 429, 500s, etc
29+
logger.warn(message: "Failed to fetch gravatar", status: resp.status, url: gravatar_url, user_id: @user.id)
30+
redirect_to default_avatar_url
31+
end
32+
end
33+
34+
private
35+
36+
def find_user
37+
@user = User.find_by_slug(params[:id])
38+
return if @user
39+
render_not_found
40+
end
41+
42+
def set_size
43+
@size = params.permit(:size).fetch(:size, 64).to_i
44+
return unless @size < 1 || @size > 2048
45+
render plain: "Invalid size", status: :bad_request
46+
end
47+
48+
def set_theme
49+
@theme = params.permit(:theme).fetch(:theme, "light")
50+
return if %w[light dark].include?(@theme)
51+
render plain: "Invalid theme", status: :bad_request
52+
end
53+
54+
def default_avatar_url
55+
case @theme
56+
when "light" then "/images/avatar.svg"
57+
when "dark" then "/images/avatar_inverted.svg"
58+
else raise "invalid default avatar theme, only light and dark are suported"
59+
end
60+
end
61+
62+
def gravatar_client
63+
Faraday.new(nil, request: { timeout: 2 }) do |f|
64+
f.response :logger, logger, headers: false, errors: true
65+
end
66+
end
67+
end

app/helpers/application_helper.rb

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,9 @@ def gem_info(rubygem)
3535
end
3636

3737
def avatar(size, id = "gravatar", user = current_user, theme: :light, **)
38-
url = user.gravatar_url(size: size, secure: true) || default_avatar(theme: theme)
38+
raise ArgumentError, "invalid default avatar theme, only light and dark are suported" unless %i[light dark].include? theme
39+
40+
url = avatar_user_path(user.id, params: { size: size, theme: theme })
3941
image_tag(url,
4042
id: id,
4143
width: size,
@@ -98,14 +100,4 @@ def search_field(home: false)
98100
data: data
99101
)
100102
end
101-
102-
private
103-
104-
def default_avatar(theme:)
105-
case theme
106-
when :light then "/images/avatar.svg"
107-
when :dark then "/images/avatar_inverted.svg"
108-
else raise "invalid default avatar theme, only light and dark are suported"
109-
end
110-
end
111103
end

app/models/user.rb

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,6 @@ def self.security_user
145145
find_by!(email: "security@rubygems.org")
146146
end
147147

148-
def gravatar_url(*)
149-
public_email ? super : nil
150-
end
151-
152148
def name
153149
handle || email
154150
end

config/routes.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,15 @@
280280
end
281281
end
282282

283+
################################################################################
284+
# UI Images
285+
286+
scope constraints: { format: /jpe?g/ }, defaults: { format: :jpeg } do
287+
resources :users, only: [] do
288+
get 'avatar', on: :member, to: 'avatars#show', format: true
289+
end
290+
end
291+
283292
################################################################################
284293
# high_voltage static routes
285294
get 'pages/*id' => 'high_voltage/pages#show', constraints: { id: Regexp.union(HighVoltage.page_ids) }, as: :page

test/integration/avatars_test.rb

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
require "test_helper"
2+
3+
class AvatarsTest < ActionDispatch::IntegrationTest
4+
test "returns 404 when no user is found" do
5+
get avatar_user_path("user", size: 64)
6+
7+
assert_response :not_found
8+
end
9+
10+
test "redirects to default avatar when gravatar returns 404" do
11+
stub_request(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s={size}"))
12+
.to_return(status: 404)
13+
14+
user = create(:user)
15+
get avatar_user_path(user.id, size: 64)
16+
17+
assert_response :found
18+
assert_equal "http://localhost/images/avatar.svg", response.headers["Location"]
19+
end
20+
21+
test "serves gravatar response on 200" do
22+
stub_request(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s=64"))
23+
.to_return(status: 200, body: "image", headers: {
24+
"Content-Type" => "image/jpeg",
25+
"Last-Modified" => "Wed, 21 Oct 2015 07:28:00 GMT",
26+
"Link" => "foo"
27+
})
28+
29+
user = create(:user)
30+
get avatar_user_path(user.id, size: 64)
31+
32+
assert_response :success
33+
assert_equal "image/jpeg", response.headers["Content-Type"]
34+
assert_equal "image", response.body
35+
assert_nil response.headers["Link"]
36+
end
37+
38+
test "serves default avatar with theme when user has no gravatar" do
39+
user = create(:user)
40+
get avatar_user_path(user.id, size: 64, theme: "dark")
41+
42+
assert_response :found
43+
assert_equal "http://localhost/images/avatar_inverted.svg", response.headers["Location"]
44+
end
45+
46+
test "falls back to default avatar when gravatar returns 500" do
47+
stub_request(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s=64"))
48+
.to_return(status: 500)
49+
50+
user = create(:user)
51+
get avatar_user_path(user.id, size: 64)
52+
53+
assert_response :found
54+
assert_equal "http://localhost/images/avatar.svg", response.headers["Location"]
55+
end
56+
57+
test "returns 400 when size is invalid" do
58+
user = create(:user)
59+
get avatar_user_path(user.id, size: 0)
60+
61+
assert_response :bad_request
62+
assert_equal "Invalid size", response.body
63+
64+
get avatar_user_path(user.id, size: 2049)
65+
66+
assert_response :bad_request
67+
assert_equal "Invalid size", response.body
68+
end
69+
70+
test "returns 400 when theme is invalid" do
71+
user = create(:user)
72+
get avatar_user_path(user.id, size: 64, theme: "unknown")
73+
74+
assert_response :bad_request
75+
assert_equal "Invalid theme", response.body
76+
end
77+
end

test/integration/routing_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
class RoutingTest < ActionDispatch::IntegrationTest
44
def contoller_in_ui?(controller)
5-
!controller.nil? && controller !~ %r{^api|internal|sendgrid_events.*|view_components(_system_test)?|turbo|admin/admin$}
5+
!controller.nil? && controller !~ %r{^api|internal|sendgrid_events.*|view_components(_system_test)?|turbo|admin/admin|avatars$}
66
end
77

88
setup do
@@ -22,7 +22,7 @@ def contoller_in_ui?(controller)
2222
@ui_paths_verb.each do |path, verb|
2323
next if path == "/" # adding random format after root (/) gives 404
2424

25-
assert_raises(ActionController::RoutingError) do
25+
assert_raises(ActionController::RoutingError, "#{verb} #{path} should raise") do
2626
# ex: get(/password/new.json)
2727
send(verb.downcase, path.gsub("(.:format)", ".something"))
2828
end

test/models/user_test.rb

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -877,14 +877,4 @@ class UserTest < ActiveSupport::TestCase
877877
assert_equal "", User.normalize_email("\u9999".force_encoding("ascii"))
878878
end
879879
end
880-
881-
context "#gravatar_url" do
882-
should "return gravatar if email is publicly visible" do
883-
assert_includes User.new(public_email: true, email: "text@example.com").gravatar_url, "gravatar.com"
884-
end
885-
886-
should "return nil if email is publicly hidden" do
887-
assert_nil User.new(public_email: false, email: "text@example.com").gravatar_url
888-
end
889-
end
890880
end

test/test_helper.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,12 +40,16 @@
4040
"chromedriver.storage.googleapis.com"
4141
]
4242
)
43-
WebMock.globally_stub_request do |request|
43+
WebMock.globally_stub_request(:after_local_stubs) do |request|
4444
avo_request_pattern = WebMock::RequestPattern.new(:post, "https://avohq.io/api/v1/licenses/check")
4545
if avo_request_pattern.matches?(request)
4646
{ status: 200, body: { id: :pro, valid: true, payload: {} }.to_json,
4747
headers: { "Content-Type" => "application/json" } }
4848
end
49+
50+
if WebMock::RequestPattern.new(:get, Addressable::Template.new("https://secure.gravatar.com/avatar/{hash}.png?d=404&r=PG&s={size}")).matches?(request)
51+
{ status: 404, body: "", headers: {} }
52+
end
4953
end
5054

5155
Capybara.default_max_wait_time = 2

test/unit/helpers/application_helper_test.rb

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ class ApplicationHelperTest < ActionView::TestCase
7878

7979
context "avatar" do
8080
setup do
81-
@user = build(:user, email: "email@example.com")
81+
@user = create(:user, email: "email@example.com")
8282
end
8383

8484
should "raise when invalid theme is requested" do
@@ -92,7 +92,7 @@ class ApplicationHelperTest < ActionView::TestCase
9292

9393
should "return gravatar" do
9494
url = avatar(160, "id", @user)
95-
expected_uri = "https://secure.gravatar.com/avatar/5658ffccee7f0ebfda2b226238b1eb6e.png?d=retro&amp;r=PG&amp;s=160"
95+
expected_uri = "/users/#{@user.id}/avatar.jpeg?size=160&amp;theme=light"
9696

9797
assert_equal "<img id=\"id\" width=\"160\" height=\"160\" src=\"#{expected_uri}\" />", url
9898
end
@@ -106,19 +106,19 @@ class ApplicationHelperTest < ActionView::TestCase
106106
should "return light themed default avatar" do
107107
url = avatar(160, "id", @user, theme: :light)
108108

109-
assert_equal "<img id=\"id\" width=\"160\" height=\"160\" src=\"/images/avatar.svg\" />", url
109+
assert_equal "<img id=\"id\" width=\"160\" height=\"160\" src=\"/users/#{@user.id}/avatar.jpeg?size=160&amp;theme=light\" />", url
110110
end
111111

112112
should "return light themed default avatar by default" do
113113
url = avatar(160, "id", @user)
114114

115-
assert_equal "<img id=\"id\" width=\"160\" height=\"160\" src=\"/images/avatar.svg\" />", url
115+
assert_equal "<img id=\"id\" width=\"160\" height=\"160\" src=\"/users/#{@user.id}/avatar.jpeg?size=160&amp;theme=light\" />", url
116116
end
117117

118118
should "return dark themed default avatar" do
119119
url = avatar(160, "id", @user, theme: :dark)
120120

121-
assert_equal "<img id=\"id\" width=\"160\" height=\"160\" src=\"/images/avatar_inverted.svg\" />", url
121+
assert_equal "<img id=\"id\" width=\"160\" height=\"160\" src=\"/users/#{@user.id}/avatar.jpeg?size=160&amp;theme=dark\" />", url
122122
end
123123
end
124124
end

0 commit comments

Comments
 (0)