From eec96ff3843c4a7593cd36ad5e963ede98e30ed5 Mon Sep 17 00:00:00 2001 From: Kevin McConnell Date: Tue, 25 Nov 2025 14:40:49 +0000 Subject: [PATCH] Serve own avatar from its own endpoint This allows us to have different cache controls depending on whether you're viewing your own avatar, or someone else's. Your own avatar will always be fresh, while other folks' avatars can be pulled from the CDN. --- app/controllers/my/avatars_controller.rb | 16 +++++++++ app/controllers/users/avatars_controller.rb | 13 +++----- app/helpers/avatars_helper.rb | 7 +++- app/views/my/avatars/show.svg.erb | 1 + app/views/users/_initials.svg.erb | 22 +++++++++++++ app/views/users/avatars/show.svg.erb | 23 +------------ config/routes.rb | 1 + .../controllers/my/avatars_controller_test.rb | 33 +++++++++++++++++++ .../users/avatars_controller_test.rb | 11 ++----- 9 files changed, 86 insertions(+), 41 deletions(-) create mode 100644 app/controllers/my/avatars_controller.rb create mode 100644 app/views/my/avatars/show.svg.erb create mode 100644 app/views/users/_initials.svg.erb create mode 100644 test/controllers/my/avatars_controller_test.rb diff --git a/app/controllers/my/avatars_controller.rb b/app/controllers/my/avatars_controller.rb new file mode 100644 index 000000000..70f714ae2 --- /dev/null +++ b/app/controllers/my/avatars_controller.rb @@ -0,0 +1,16 @@ +class My::AvatarsController < ApplicationController + def show + if stale? Current.user + if Current.user.avatar.attached? + redirect_to rails_blob_url(Current.user.avatar_thumbnail, disposition: "inline") + else + render_initials + end + end + end + + private + def render_initials + render formats: :svg + end +end diff --git a/app/controllers/users/avatars_controller.rb b/app/controllers/users/avatars_controller.rb index d85e5f869..989387981 100644 --- a/app/controllers/users/avatars_controller.rb +++ b/app/controllers/users/avatars_controller.rb @@ -7,17 +7,20 @@ class Users::AvatarsController < ApplicationController before_action :ensure_permission_to_administer_user, only: :destroy def show + expires_in 30.minutes, public: true, stale_while_revalidate: 1.week + if @user.system? redirect_to view_context.image_path("system_user.png") elsif @user.avatar.attached? redirect_to rails_blob_url(@user.avatar_thumbnail, disposition: "inline") - elsif stale? @user, cache_control: cache_control + else render_initials end end def destroy @user.avatar.destroy + @user.touch redirect_to @user end @@ -30,14 +33,6 @@ class Users::AvatarsController < ApplicationController head :forbidden unless Current.user.can_change?(@user) end - def cache_control - if @user == Current.user - {} - else - { max_age: 30.minutes, stale_while_revalidate: 1.week } - end - end - def render_initials render formats: :svg end diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb index 7c064a650..a30f20962 100644 --- a/app/helpers/avatars_helper.rb +++ b/app/helpers/avatars_helper.rb @@ -42,6 +42,11 @@ module AvatarsHelper end def avatar_image_tag(user, **options) - image_tag user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, **options + tag.span data: { creator_id: user.id } do + safe_join [ + image_tag(user_avatar_url(user, script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, data: { only_visible_to_others: true }, **options), + image_tag(my_avatar_url(script_name: user.account.slug), aria: { hidden: "true" }, size: 48, title: user.name, data: { only_visible_to_you: true }, **options) + ] + end end end diff --git a/app/views/my/avatars/show.svg.erb b/app/views/my/avatars/show.svg.erb new file mode 100644 index 000000000..adf20ff54 --- /dev/null +++ b/app/views/my/avatars/show.svg.erb @@ -0,0 +1 @@ +<%= render "users/initials", user: Current.user %> diff --git a/app/views/users/_initials.svg.erb b/app/views/users/_initials.svg.erb new file mode 100644 index 000000000..7d79adfb2 --- /dev/null +++ b/app/views/users/_initials.svg.erb @@ -0,0 +1,22 @@ + diff --git a/app/views/users/avatars/show.svg.erb b/app/views/users/avatars/show.svg.erb index 06b2dec0d..b6e34aeb7 100644 --- a/app/views/users/avatars/show.svg.erb +++ b/app/views/users/avatars/show.svg.erb @@ -1,22 +1 @@ - +<%= render "users/initials", user: @user %> diff --git a/config/routes.rb b/config/routes.rb index aa2815433..ce7d4fe90 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -164,6 +164,7 @@ Rails.application.routes.draw do resource :landing namespace :my do + resource :avatar, only: :show resource :identity, only: :show resources :access_tokens resources :pins diff --git a/test/controllers/my/avatars_controller_test.rb b/test/controllers/my/avatars_controller_test.rb new file mode 100644 index 000000000..c2081d121 --- /dev/null +++ b/test/controllers/my/avatars_controller_test.rb @@ -0,0 +1,33 @@ +require "test_helper" + +class My::AvatarsControllerTest < ActionDispatch::IntegrationTest + setup do + sign_in_as :david + end + + test "show own initials" do + get my_avatar_path + assert_match "image/svg+xml", @response.content_type + assert_match "private", @response.headers["Cache-Control"] + assert_match "must-revalidate", @response.headers["Cache-Control"] + end + + test "show own image redirects to the blob url" do + users(:david).avatar.attach(io: File.open(file_fixture("moon.jpg")), filename: "moon.jpg", content_type: "image/jpeg") + assert users(:david).avatar.attached? + + get my_avatar_path + + assert_redirected_to rails_blob_url(users(:david).avatar.variant(:thumb), disposition: "inline") + assert_match "private", @response.headers["Cache-Control"] + assert_match "must-revalidate", @response.headers["Cache-Control"] + end + + test "requires authentication" do + sign_out + + get my_avatar_path + + assert_response :redirect + end +end diff --git a/test/controllers/users/avatars_controller_test.rb b/test/controllers/users/avatars_controller_test.rb index 3ded884b8..783d5aeba 100644 --- a/test/controllers/users/avatars_controller_test.rb +++ b/test/controllers/users/avatars_controller_test.rb @@ -12,17 +12,10 @@ class Users::AvatarsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to ActionController::Base.helpers.image_path("system_user.png") end - test "show own initials without caching" do + test "show initials with public caching" do get user_avatar_path(users(:david)) assert_match "image/svg+xml", @response.content_type - assert @response.cache_control[:private] - assert_equal "0", @response.cache_control[:max_age] - end - - test "show other initials with caching" do - get user_avatar_path(users(:kevin)) - assert_match "image/svg+xml", @response.content_type - assert @response.cache_control[:private] + assert @response.cache_control[:public] assert_equal 30.minutes.to_s, @response.cache_control[:max_age] end