Project

General

Profile

Feature #12197 » 0001-Allow-users-to-set-their-avatar-in-Redmine.patch

Go MAEDA, 2026-10-15 11:04

View differences:

app/assets/stylesheets/application.css
2364 2364
}
2365 2365
h2 .avatar, h3 .avatar {margin-inline-end: 4px;}
2366
h4 img.gravatar {margin-block: -2px -4px; margin-inline: 0 4px;}
2366
h4 img.avatar {margin-block: -2px -4px; margin-inline: 0 4px;}
2367 2367
#activity dt .avatar {
2368 2368
  margin-block: 0;
2369 2369
  margin-inline: 0 1em;
app/assets/stylesheets/responsive.css
494 494
    border-block-start: 1px solid rgba(255,255,255,.1);
495 495
  }
496
  .flyout-menu__avatar img.gravatar {
496
  .flyout-menu__avatar img.avatar {
497 497
    inline-size: 40px;
498 498
    block-size: 40px;
499 499
    padding: 0;
app/controllers/avatars_controller.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
class AvatarsController < ApplicationController
21
  before_action :find_avatar
22
  before_action :check_avatar_available
23

  
24
  def show
25
    # The original image is never served since it may contain metadata
26
    # such as the location where it was taken
27
    file = @avatar.thumbnail(:size => params[:size], :crop => true)
28
    unless file
29
      render_404
30
      return
31
    end
32

  
33
    expires_in 1.day, :public => !Setting.login_required?
34
    if stale?(:etag => file, :template => false)
35
      send_file(
36
        file,
37
        :filename => filename_for_content_disposition(@avatar.filename),
38
        :type => @avatar.content_type,
39
        :disposition => 'inline')
40
    end
41
  end
42

  
43
  private
44

  
45
  def find_avatar
46
    user = User.find_by(:id => params[:user_id], :avatar_id => params[:id])
47
    @avatar = user&.avatar
48
    render_404 unless @avatar&.readable?
49
  end
50

  
51
  def check_avatar_available
52
    render_404 unless User.avatar_available?
53
  end
54
end
app/helpers/avatars_helper.rb
34 34
    avatar(user, options).to_s.html_safe
35 35
  end
36
  # Returns the full URL of the avatar image of +user+ for the API, or nil
37
  def avatar_image_url(user)
38
    if local_avatar?(user)
39
      user_avatar_url(user, user.avatar_id)
40
    elsif user.mail && Setting.gravatar_enabled?
41
      gravatar_url(user.mail, {rating: nil, size: nil, default: Setting.gravatar_default})
42
    end
43
  end
44

  
36 45
  # Returns the avatar image tag for the given +user+ if avatars are enabled
37 46
  # +user+ can be a User or a string that will be scanned for an email address (eg. 'joe <joe@foo.bar>')
38 47
  def avatar(user, options = {})
......
46 55
      anonymous_avatar(options)
47 56
    elsif user.is_a?(Group)
48 57
      group_avatar(options)
58
    elsif local_avatar?(user)
59
      local_avatar_tag(user, options)
49 60
    elsif Setting.gravatar_enabled?
50 61
      gravatar_avatar_tag(user, options)
51 62
    elsif user.respond_to?(:initials)
......
75 86
  private
87
  def local_avatar?(user)
88
    user.is_a?(User) && user.avatar_id && User.avatar_available?
89
  end
90

  
91
  def local_avatar_tag(user, options)
92
    size = (options.delete(:size) || GravatarHelper::DEFAULT_OPTIONS[:size]).to_i
93
    options[:title] ||= user.name
94
    options[:srcset] = "#{user_avatar_path(user, user.avatar_id, :size => size * 2)} 2x"
95
    image_tag(
96
      user_avatar_path(user, user.avatar_id, :size => size),
97
      GravatarHelper::DEFAULT_OPTIONS.slice(:alt).merge(options).merge(:width => size, :height => size))
98
  end
99

  
76 100
  def gravatar_avatar_tag(user, options)
77 101
    options[:default] = Setting.gravatar_default
78 102
    options[:class] = [GravatarHelper::DEFAULT_OPTIONS[:class], options[:class]].compact.join(' ')
app/models/attachment.rb
262 262
  # Returns the full path the attachment thumbnail, or nil
263 263
  # if the thumbnail cannot be generated.
264
  # Pass :crop => true to get a square thumbnail cropped around the center.
264 265
  def thumbnail(options={})
265 266
    if thumbnailable? && readable?
266 267
      size = options[:size].to_i
......
274 275
        size = Setting.thumbnails_size.to_i
275 276
      end
276 277
      size = 100 unless size > 0
277
      target = thumbnail_path(size)
278
      crop = !!options[:crop]
279
      target = thumbnail_path(crop ? "#{size}_crop" : size)
278 280
      begin
279 281
        # TODO: Stop passing the deprecated is_pdf flag in Redmine 7.0
280
        Redmine::Thumbnail.generate(self.diskfile, target, size, is_pdf?)
282
        Redmine::Thumbnail.generate(self.diskfile, target, size, is_pdf?, :crop => crop)
281 283
      rescue => e
282 284
        if logger
283 285
          logger.error(
app/models/user.rb
109 109
           :foreign_key => :resource_owner_id, :dependent => :delete_all
110 110
  belongs_to :auth_source
111
  belongs_to :avatar, :class_name => 'Attachment', :dependent => :destroy
111 112
  scope :logged, lambda {where("#{User.table_name}.status <> #{STATUS_ANONYMOUS}")}
112 113
  scope :status, lambda {|arg| where(arg.blank? ? nil : {:status => arg.to_i})}
......
120 121
  attr_accessor :remote_ip
121 122
  attr_writer   :oauth_scope
123
  AVATAR_CONTENT_TYPES = %w(image/avif image/gif image/jpeg image/png image/webp).freeze
124

  
122 125
  LOGIN_LENGTH_LIMIT = 60
123 126
  MAIL_LENGTH_LIMIT = 254
......
425 428
    self.preference ||= UserPreference.new(:user => self)
426 429
  end
430
  # Avatars are served only as thumbnails, which require ImageMagick
431
  def self.avatar_available?
432
    Setting.avatar_enabled? && Redmine::Thumbnail.convert_available?
433
  end
434

  
435
  # Replaces the avatar with the uploaded +file+ and returns true, or returns
436
  # false with errors if the file cannot be used as an avatar
437
  def update_avatar(file)
438
    attachment = Attachment.new(:file => file, :author => User.current, :container => self)
439
    # Avatars are served only as thumbnails
440
    unless User.avatar_available? && AVATAR_CONTENT_TYPES.include?(Marcel::MimeType.for(file)) && attachment.thumbnailable?
441
      errors.add(:avatar, :invalid)
442
      return false
443
    end
444

  
445
    unless attachment.valid?
446
      attachment.errors.full_messages.each {|message| errors.add(:base, message)}
447
      return false
448
    end
449

  
450
    transaction do
451
      attachment.save!
452
      replace_avatar(attachment)
453
    end
454
    true
455
  end
456

  
457
  def delete_avatar
458
    replace_avatar(nil)
459
  end
460

  
461
  # Unlike the thumbnails, the original image of the avatar may contain
462
  # metadata such as the location where it was taken
463
  def attachments_visible?(user=User.current)
464
    user == self || user.admin?
465
  end
466

  
467
  # Avatars can only be changed through AvatarsController
468
  def attachments_editable?(user=User.current)
469
    false
470
  end
471

  
472
  def attachments_deletable?(user=User.current)
473
    false
474
  end
475

  
427 476
  def time_zone
428 477
    @time_zone ||= (self.pref.time_zone.blank? ? nil : ActiveSupport::TimeZone[self.pref.time_zone])
429 478
  end
......
975 1024
  private
1025
  def replace_avatar(attachment)
1026
    old_avatar = avatar
1027
    self.avatar = attachment
1028
    update_column(:avatar_id, avatar_id)
1029
    old_avatar&.destroy
1030
  end
1031

  
976 1032
  def generate_password_if_needed
977 1033
    if generate_password? && auth_source.nil?
978 1034
      length = [Setting.password_min_length.to_i + 2, 10].max
app/views/settings/_display.html.erb
19 19
<p><%= setting_select :user_format, @options[:user_format] %></p>
20
<p><%= setting_check_box :avatar_enabled %>
21
<% unless Redmine::Thumbnail.convert_available? %>
22
<em class="info"><span class="icon icon-warning"><%= sprite_icon('warning', l(:text_setting_avatar_enabled_imagemagick_unavailable)) %></span></em>
23
<% end %></p>
24

  
20 25
<p><%= setting_check_box :gravatar_enabled, :data => {:enables => '#settings_gravatar_default'} %>
21 26
<em class="info"><%= t(:text_avatar_server_config_html, :url => Redmine::Configuration['avatar_server_url']) %></em></p>
app/views/users/index.api.rsb
11 11
      api.updated_on user.updated_on
12 12
      api.last_login_on     user.last_login_on
13 13
      api.passwd_changed_on user.passwd_changed_on
14
      api.avatar_url gravatar_url(user.mail, {rating: nil, size: nil, default: Setting.gravatar_default}) if Setting.gravatar_enabled?
14
      avatar_url = avatar_image_url(user)
15
      api.avatar_url avatar_url if avatar_url
15 16
      api.twofa_scheme user.twofa_scheme
16 17
      api.status user.status
app/views/users/show.api.rsb
9 9
  api.updated_on @user.updated_on
10 10
  api.last_login_on     @user.last_login_on
11 11
  api.passwd_changed_on @user.passwd_changed_on
12
  api.avatar_url gravatar_url(@user.mail, {rating: nil, size: nil, default: Setting.gravatar_default}) if @user.mail && Setting.gravatar_enabled?
12
  avatar_url = avatar_image_url(@user)
13
  api.avatar_url avatar_url if avatar_url
13 14
  api.twofa_scheme      @user.twofa_scheme if User.current.admin? || (User.current == @user)
14 15
  api.api_key    @user.api_key if (User.current.admin? || (User.current == @user && !User.current.authorized_by_oauth?))
15 16
  api.status     @user.status if User.current.admin?
config/locales/en.yml
357 357
  field_start_date: Start date
358 358
  field_done_ratio: "% Done"
359 359
  field_auth_source: Authentication mode
360
  field_avatar: Avatar
360 361
  field_hide_mail: Hide my email address
361 362
  field_comments: Comment
362 363
  field_url: URL
......
479 480
  setting_mail_handler_preferred_body_part: Preferred part of multipart (HTML) emails
480 481
  setting_sys_api_key: Repository management WS API key
481 482
  setting_sequential_project_identifiers: Generate sequential project identifiers
483
  setting_avatar_enabled: Allow users to set their avatar
482 484
  setting_gravatar_enabled: Use Gravatar user icons
483 485
  setting_gravatar_default: Default Gravatar image
484 486
  setting_diff_max_lines_displayed: Maximum number of diff lines displayed
......
1398 1400
  text_allowed_queries_to_select: Public (to any users) queries only selectable
1399 1401
  text_setting_config_change: You can configure the behaviour in config/configuration.yml. Please restart the application after editing it.
1400 1402
  text_setting_gravatar_default_initials_html: Users' initials are sent to <a href="%{url}">%{url}</a> to generate their avatars.
1403
  text_setting_avatar_enabled_imagemagick_unavailable: ImageMagick is not available, so users cannot set their avatar.
1401 1404
  text_oauth_admin_permission: Full administrative access. When authorized by an Administrator, this application will be able to read and write all data and impersonate other users.
1402 1405
  text_oauth_admin_permission_info: This application requests full administrative access. If you are an Administrator (or become one in the future), it will be able to read and write all data and impersonate other users on your behalf. If you want to avoid this, authorize it as a user without Administrator privileges instead.
1403 1406
  text_oauth_copy_secret_now: Copy the secret to a safe place now, it will not be shown again.
config/routes.rb
129 129
    resources :memberships, :controller => 'principal_memberships'
130 130
    resources :email_addresses, :only => [:index, :create, :update, :destroy]
131 131
  end
132
  get 'users/:user_id/avatar/:id(/:size)', :to => 'avatars#show', :user_id => /\d+/, :id => /\d+/, :size => /\d+/, :as => 'user_avatar'
132 133
  post 'watchers/watch', :to => 'watchers#watch', :as => 'watch'
133 134
  delete 'watchers/watch', :to => 'watchers#unwatch'
config/settings.yml
328 328
  default: |-
329 329
    You have received this notification because you have either subscribed to it, or are involved in it.
330 330
    To change your notification preferences, please click here: http://hostname/my/account
331
avatar_enabled:
332
  default: 1
331 333
gravatar_enabled:
332 334
  default: 0
333 335
gravatar_default:
db/migrate/20261015000046_add_avatar_id_to_users.rb
1
class AddAvatarIdToUsers < ActiveRecord::Migration[8.1]
2
  def change
3
    add_column :users, :avatar_id, :integer
4
  end
5
end
lib/redmine/thumbnail.rb
33 33
    ALLOWED_TYPES = %w(image/avif image/bmp image/gif image/jpeg image/png image/webp application/pdf)
34 34
    # Generates a thumbnail for the source image to target
35
    # If crop is true, the image is cropped to a square around its center
35 36
    # TODO: Remove the deprecated _is_pdf parameter in Redmine 7.0
36
    def self.generate(source, target, size, _is_pdf = nil)
37
    def self.generate(source, target, size, _is_pdf = nil, crop: false)
37 38
      return nil unless convert_available?
38 39
      unless File.exist?(target)
......
54 55
          return nil unless valid_pdf_magic?(source)
55 56
          cmd = "#{shell_quote CONVERT_BIN} #{shell_quote "#{source}[0]"} -thumbnail #{shell_quote size_option} #{shell_quote "png:#{output}"}"
57
        elsif crop
58
          cmd = "#{shell_quote CONVERT_BIN} #{shell_quote source} -auto-orient -thumbnail #{shell_quote "#{size}x#{size}^"} " \
59
                "-gravity center -extent #{shell_quote "#{size}x#{size}"} #{shell_quote output}"
56 60
        else
57 61
          cmd = "#{shell_quote CONVERT_BIN} #{shell_quote source} -auto-orient -thumbnail #{shell_quote size_option} #{shell_quote output}"
58 62
        end
test/functional/avatars_controller_test.rb
1
# frozen_string_literal: true
2

  
3
# Redmine - project management software
4
# Copyright (C) 2006-  Jean-Philippe Lang
5
#
6
# This program is free software; you can redistribute it and/or
7
# modify it under the terms of the GNU General Public License
8
# as published by the Free Software Foundation; either version 2
9
# of the License, or (at your option) any later version.
10
#
11
# This program is distributed in the hope that it will be useful,
12
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
14
# GNU General Public License for more details.
15
#
16
# You should have received a copy of the GNU General Public License
17
# along with this program; if not, write to the Free Software
18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19

  
20
require_relative '../test_helper'
21

  
22
class AvatarsControllerTest < Redmine::ControllerTest
23
  def setup
24
    User.current = nil
25
    set_fixtures_attachments_directory
26
    User.find(2).update_column(:avatar_id, 16)
27
  end
28

  
29
  def teardown
30
    set_tmp_attachments_directory
31
  end
32

  
33
  def test_show
34
    skip unless convert_installed?
35

  
36
    get :show, :params => {:user_id => 2, :id => 16, :size => 48}
37
    assert_response :success
38
    assert_equal 'image/png', response.media_type
39
    assert_match /\Ainline/, response.headers['Content-Disposition']
40
    assert_equal "max-age=#{1.day.to_i}, public", response.headers['Cache-Control']
41
  end
42

  
43
  def test_show_should_not_serve_original_image_if_thumbnail_cannot_be_generated
44
    Attachment.any_instance.stubs(:thumbnail).returns(nil)
45
    get :show, :params => {:user_id => 2, :id => 16}
46
    assert_response :not_found
47
  end
48

  
49
  def test_show_should_return_404_with_avatars_disabled
50
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
51
    with_settings :avatar_enabled => '0' do
52
      get :show, :params => {:user_id => 2, :id => 16}
53
    end
54
    assert_response :not_found
55
  end
56

  
57
  def test_show_should_return_404_for_replaced_avatar
58
    get :show, :params => {:user_id => 2, :id => 15}
59
    assert_response :not_found
60
  end
61

  
62
  def test_show_should_require_login_if_login_is_required
63
    with_settings :login_required => '1' do
64
      get :show, :params => {:user_id => 2, :id => 16}
65
      assert_response :redirect
66
    end
67
  end
68
end
test/helpers/avatars_helper_test.rb
31 31
    assert_include Digest::SHA256.hexdigest('jsmith@somenet.foo'), avatar(User.find_by_mail('jsmith@somenet.foo'))
32 32
  end
33
  def test_avatar_with_user_having_local_avatar
34
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
35
    user = User.find(2)
36
    user.avatar_id = 16
37

  
38
    avatar_tag = avatar(user, :size => 24)
39
    assert_include 'src="/users/2/avatar/16/24"', avatar_tag
40
    assert_include 'srcset="/users/2/avatar/16/48 2x"', avatar_tag
41
    assert_include 'width="24" height="24"', avatar_tag
42
  end
43

  
44
  def test_avatar_should_ignore_local_avatar_without_imagemagick
45
    Redmine::Thumbnail.stubs(:convert_available?).returns(false)
46
    user = User.find(2)
47
    user.avatar_id = 16
48

  
49
    assert_include Digest::SHA256.hexdigest('jsmith@somenet.foo'), avatar(user)
50
  end
51

  
33 52
  def test_avatar_with_email_string
34 53
    assert_include Digest::SHA256.hexdigest('jsmith@somenet.foo'), avatar('jsmith <jsmith@somenet.foo>')
35 54
  end
test/integration/api_test/users_test.rb
168 168
    assert_select 'user avatar_url', :text => %r|\Ahttps://gravatar.com/avatar/\h{64}\?default=robohash|
169 169
  end
170
  test "GET /users/:id.xml should return the URL of the uploaded avatar" do
171
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
172
    User.find(2).update_column(:avatar_id, 16)
173
    with_settings :gravatar_enabled => '1' do
174
      get '/users/2.xml'
175
    end
176

  
177
    assert_response :success
178
    assert_select 'user avatar_url', :text => 'http://www.example.com/users/2/avatar/16'
179
  end
180

  
170 181
  test "GET /users/:id.xml should not return avatar_url when not set email address" do
171 182
    user = User.find(2)
172 183
    user.email_addresses.delete_all
test/integration/routing/users_test.rb
34 34
    should_route  'DELETE /users/bulk_destroy' => 'users#bulk_destroy'
35 35
    should_route  'POST /users/bulk_lock' => 'users#bulk_lock'
36 36
    should_route  'POST /users/bulk_unlock' => 'users#bulk_unlock'
37

  
38
    should_route 'GET /users/44/avatar/2/48' => 'avatars#show', :user_id => '44', :id => '2', :size => '48'
37 39
  end
38 40
end
test/unit/lib/redmine/thumbnail_test.rb
68 68
  ensure
69 69
    FileUtils.rm_f(target) if target
70 70
  end
71

  
72
  def test_thumbnail_with_crop_should_be_square
73
    skip unless Redmine::Thumbnail.convert_available?
74

  
75
    set_tmp_attachments_directory
76
    file = file_fixture('2010/11/101123161450_testfile_1.png')
77
    target = File.join(Attachment.storage_path, "#{SecureRandom.hex(8)}.thumb")
78
    Redmine::Thumbnail.generate(file.to_s, target, 50, :crop => true)
79
    # Width and height in the IHDR chunk of the PNG file
80
    assert_equal [50, 50], File.binread(target, 8, 16).unpack('NN')
81
  ensure
82
    FileUtils.rm_f(target) if target
83
  end
71 84
end
test/unit/user_test.rb
1568 1568
      users(:users_004).destroy
1569 1569
    end
1570 1570
  end
1571

  
1572
  def test_update_avatar_should_save_image_as_attachment_of_user
1573
    set_tmp_attachments_directory
1574
    assert update_avatar(@jsmith, '2010/11/101123161450_testfile_1.png')
1575

  
1576
    avatar = @jsmith.reload.avatar
1577
    assert_equal @jsmith, avatar.container
1578
    assert_equal 'image/png', avatar.content_type
1579
  end
1580

  
1581
  def test_avatar_attachment_should_be_visible_only_to_user_and_administrators
1582
    set_tmp_attachments_directory
1583
    update_avatar(@jsmith, '2010/11/101123161450_testfile_1.png')
1584

  
1585
    avatar = @jsmith.reload.avatar
1586
    assert avatar.visible?(@jsmith)
1587
    assert avatar.visible?(@admin)
1588
    assert_not avatar.visible?(@dlopper)
1589
  end
1590

  
1591
  def test_avatar_available_should_require_setting_and_imagemagick
1592
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
1593
    assert User.avatar_available?
1594
    with_settings :avatar_enabled => '0' do
1595
      assert_not User.avatar_available?
1596
    end
1597

  
1598
    Redmine::Thumbnail.stubs(:convert_available?).returns(false)
1599
    assert_not User.avatar_available?
1600
  end
1601

  
1602
  def test_update_avatar_should_reject_images_without_imagemagick
1603
    Redmine::Thumbnail.stubs(:convert_available?).returns(false)
1604
    assert_no_difference 'Attachment.count' do
1605
      assert_not @jsmith.update_avatar(uploaded_test_file('2010/11/101123161450_testfile_1.png', 'image/png'))
1606
    end
1607
  end
1608

  
1609
  def test_update_avatar_should_reject_non_raster_images
1610
    assert_no_difference 'Attachment.count' do
1611
      assert_not update_avatar(@jsmith, 'testfile.svg')
1612
    end
1613
    assert_include :avatar, @jsmith.errors.attribute_names
1614
  end
1615

  
1616
  def test_update_avatar_should_replace_existing_avatar
1617
    set_tmp_attachments_directory
1618
    update_avatar(@jsmith, '2010/11/101123161450_testfile_1.png')
1619
    old_avatar_id = @jsmith.avatar_id
1620

  
1621
    update_avatar(@jsmith, '2010/12/101223161450_testfile_2.png')
1622
    assert_not_equal old_avatar_id, @jsmith.reload.avatar_id
1623
    assert_not Attachment.exists?(old_avatar_id)
1624
  end
1625

  
1626
  def test_delete_avatar_should_destroy_avatar
1627
    set_tmp_attachments_directory
1628
    update_avatar(@jsmith, '2010/11/101123161450_testfile_1.png')
1629
    avatar_id = @jsmith.avatar_id
1630

  
1631
    @jsmith.delete_avatar
1632
    assert_nil @jsmith.reload.avatar_id
1633
    assert_not Attachment.exists?(avatar_id)
1634
  end
1635

  
1636
  def test_destroy_should_destroy_avatar
1637
    set_tmp_attachments_directory
1638
    update_avatar(@jsmith, '2010/11/101123161450_testfile_1.png')
1639

  
1640
    assert_difference 'Attachment.count', -1 do
1641
      @jsmith.destroy
1642
    end
1643
  end
1644

  
1645
  private
1646

  
1647
  def update_avatar(user, name)
1648
    Redmine::Thumbnail.stubs(:convert_available?).returns(true)
1649
    user.update_avatar(uploaded_test_file(name, Redmine::MimeType.of(name)))
1650
  end
1571 1651
end
(4-4/6)