From 6df39f51042ca9130acdc48d92d2c8c6b7dbd61c Mon Sep 17 00:00:00 2001 From: MAEDA Go Date: Tue, 20 Oct 2026 16:40:28 +0900 Subject: [PATCH] Allow administrators to upload a custom favicon --- app/controllers/favicons_controller.rb | 35 ++++++++++++++++++++ app/controllers/settings_controller.rb | 8 ++++- app/helpers/application_helper.rb | 6 +++- app/models/setting.rb | 20 ++++++++++++ app/views/settings/_display.html.erb | 13 +++++++- config/locales/en.yml | 2 ++ config/routes.rb | 3 ++ config/settings.yml | 3 ++ test/functional/favicons_controller_test.rb | 36 +++++++++++++++++++++ test/functional/settings_controller_test.rb | 31 ++++++++++++++++++ test/integration/lib/redmine/themes_test.rb | 13 ++++++++ 11 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 app/controllers/favicons_controller.rb create mode 100644 test/functional/favicons_controller_test.rb diff --git a/app/controllers/favicons_controller.rb b/app/controllers/favicons_controller.rb new file mode 100644 index 000000000..830c47b8f --- /dev/null +++ b/app/controllers/favicons_controller.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +class FaviconsController < ApplicationController + # The favicon is also shown on the login page + skip_before_action :check_if_login_required, :check_password_change, :check_twofa_activation + + def show + favicon = Setting.favicon + if favicon['digest'] != params[:digest] + render_404 + return + end + + # The URL changes whenever the favicon is replaced + expires_in 1.year, :public => true, :immutable => true + send_data Base64.decode64(favicon['data']), :type => favicon['content_type'], :disposition => 'inline' + end +end diff --git a/app/controllers/settings_controller.rb b/app/controllers/settings_controller.rb index 433a1120b..7c8d27641 100644 --- a/app/controllers/settings_controller.rb +++ b/app/controllers/settings_controller.rb @@ -26,7 +26,7 @@ class SettingsController < ApplicationController before_action :require_admin - require_sudo_mode :index, :edit, :plugin + require_sudo_mode :index, :edit, :delete_favicon, :plugin def index edit @@ -65,6 +65,12 @@ class SettingsController < ApplicationController Redmine::Themes.rescan end + def delete_favicon + Setting.favicon = {} + flash[:notice] = l(:notice_successful_delete) + redirect_to settings_path(:tab => 'display') + end + def plugin @plugin = Redmine::Plugin.find(params[:id]) unless @plugin.configurable? diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index f906d0ddf..52cdd7518 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -1856,11 +1856,15 @@ module ApplicationHelper end def favicon - favicon_link_tag(favicon_path, rel: "shortcut icon") + options = {rel: "shortcut icon"} + options[:type] = Setting.favicon['content_type'] if Setting.favicon.present? + favicon_link_tag(favicon_path, options) end # Returns the path to the favicon def favicon_path + return custom_favicon_path(Setting.favicon['digest']) if Setting.favicon.present? + icon = (current_theme && current_theme.favicon?) ? current_theme.favicon_path : 'favicon.ico' image_path(icon) end diff --git a/app/models/setting.rb b/app/models/setting.rb index 8a974bc5b..fd0057b46 100644 --- a/app/models/setting.rb +++ b/app/models/setting.rb @@ -82,6 +82,9 @@ class Setting < ApplicationRecord Big5-HKSCS TIS-620) + FAVICON_MAX_SIZE = 32.kilobytes + FAVICON_CONTENT_TYPES = %w(image/png image/vnd.microsoft.icon) + cattr_accessor :available_settings self.available_settings ||= {} @@ -208,6 +211,14 @@ class Setting < ApplicationRecord end end end + if settings.key?(:favicon) + file = settings[:favicon] + unless file.respond_to?(:original_filename) && + file.size <= FAVICON_MAX_SIZE && + FAVICON_CONTENT_TYPES.include?(Marcel::MimeType.for(file)) + messages << [:favicon, l('activerecord.errors.messages.invalid')] + end + end messages end @@ -254,6 +265,15 @@ class Setting < ApplicationRecord params end + def self.favicon_from_params(file) + data = file.read + { + 'content_type' => Marcel::MimeType.for(StringIO.new(data)), + 'data' => Base64.strict_encode64(data), + 'digest' => Digest::SHA256.hexdigest(data)[0, 8] + } + end + def self.default_issue_due_date_offset_from_params(params) params.to_s.strip end diff --git a/app/views/settings/_display.html.erb b/app/views/settings/_display.html.erb index 14e11e2af..c4b325c09 100644 --- a/app/views/settings/_display.html.erb +++ b/app/views/settings/_display.html.erb @@ -1,8 +1,19 @@ -<%= form_tag({:action => 'edit', :tab => 'display'}) do %> +<%= form_tag({:action => 'edit', :tab => 'display'}, :multipart => true) do %>

<%= setting_select :ui_theme, Redmine::Themes.themes.collect {|t| [t.name, t.id]}, :blank => :label_default, :label => :label_theme %>

+

+ <%= setting_label :favicon %> + <% if Setting.favicon.present? %> + <%= image_tag custom_favicon_path(Setting.favicon['digest']), :size => 32, :alt => '' %> + <%= delete_link settings_favicon_path %> +
+ <% end %> + <%= file_field_tag 'settings[favicon]', :accept => '.ico,.png' %> + <%= l(:text_setting_favicon_format, :max_size => number_to_human_size(Setting::FAVICON_MAX_SIZE)) %> +

+

<%= setting_select :default_language, lang_options_for_select(false) %>

<%= setting_check_box :force_default_language_for_anonymous %>

diff --git a/config/locales/en.yml b/config/locales/en.yml index 8f9917b3b..cc5aff457 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -440,6 +440,7 @@ en: setting_app_title: Application title setting_welcome_text: Welcome text setting_default_language: Default language + setting_favicon: Custom favicon setting_login_required: Authentication required setting_self_registration: Self-registration setting_show_custom_fields_on_registration: Show custom fields on registration @@ -1407,6 +1408,7 @@ en: text_setting_config_change: You can configure the behaviour in config/configuration.yml. Please restart the application after editing it. text_setting_gravatar_default_initials_html: Users' initials are sent to %{url} to generate their avatars. text_setting_avatar_enabled_imagemagick_unavailable: ImageMagick is not available, so users cannot set their avatar. + text_setting_favicon_format: "PNG or ICO, up to %{max_size}" text_setting_gravatar_enabled_fallback: If enabled, Gravatar is used for users who have not set their avatar in Redmine. 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. 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. diff --git a/config/routes.rb b/config/routes.rb index 4a4f0f859..e6f9e9232 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -424,8 +424,11 @@ Rails.application.routes.draw do end end + get 'favicons/:digest', :to => 'favicons#show', :digest => /\h{8}/, :as => 'custom_favicon', :format => false + match 'settings', :controller => 'settings', :action => 'index', :via => :get match 'settings/edit', :controller => 'settings', :action => 'edit', :via => [:get, :post] + delete 'settings/favicon', :controller => 'settings', :action => 'delete_favicon', :as => 'settings_favicon' match 'settings/plugin/:id', :controller => 'settings', :action => 'plugin', :via => [:get, :post], :as => 'plugin_settings' match 'sys/projects', :to => 'sys#projects', :via => :get diff --git a/config/settings.yml b/config/settings.yml index c9b2c6282..f67118a6a 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -324,6 +324,9 @@ repository_log_display_limit: default: 100 ui_theme: default: '' +favicon: + serialized: true + default: {} emails_footer: default: |- You have received this notification because you have either subscribed to it, or are involved in it. diff --git a/test/functional/favicons_controller_test.rb b/test/functional/favicons_controller_test.rb new file mode 100644 index 000000000..e9143cac3 --- /dev/null +++ b/test/functional/favicons_controller_test.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +# Redmine - project management software +# Copyright (C) 2006- Jean-Philippe Lang +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +require_relative '../test_helper' + +class FaviconsControllerTest < Redmine::ControllerTest + def test_show_should_be_cached_and_available_without_login + data = File.binread(file_fixture('2010/11/101123161450_testfile_1.png')) + digest = Digest::SHA256.hexdigest(data)[0, 8] + favicon = {'content_type' => 'image/png', 'data' => Base64.strict_encode64(data), 'digest' => digest} + with_settings :favicon => favicon, :login_required => '1' do + get :show, :params => {:digest => digest} + assert_response :success + + assert_equal 'image/png', response.media_type + assert_equal data, response.body + assert_equal "max-age=#{1.year.to_i}, public, immutable", response.headers['Cache-Control'] + end + end +end diff --git a/test/functional/settings_controller_test.rb b/test/functional/settings_controller_test.rb index 159fa1c97..7d9ee0f8d 100644 --- a/test/functional/settings_controller_test.rb +++ b/test/functional/settings_controller_test.rb @@ -299,6 +299,37 @@ class SettingsControllerTest < Redmine::ControllerTest Redmine::Plugin.unregister(:foo) end + def test_post_edit_should_store_favicon + with_settings :favicon => {} do + post :edit, :params => { + :settings => {:favicon => fixture_file_upload('2010/11/101123161450_testfile_1.png', 'image/png')} + } + assert_redirected_to '/settings' + + data = File.binread(file_fixture('2010/11/101123161450_testfile_1.png')) + assert_equal 'image/png', Setting.favicon['content_type'] + assert_equal data, Base64.decode64(Setting.favicon['data']) + assert_equal Digest::SHA256.hexdigest(data)[0, 8], Setting.favicon['digest'] + end + end + + def test_post_edit_should_not_store_favicon_that_is_not_an_icon_image + post :edit, :params => {:settings => {:favicon => fixture_file_upload('testfile.svg', 'image/svg+xml')}} + assert_response :success + + assert_select_error /Custom favicon is invalid/ + assert_equal({}, Setting.favicon) + end + + def test_delete_favicon + with_settings :favicon => {'content_type' => 'image/png', 'data' => 'AA==', 'digest' => '0' * 8} do + delete :delete_favicon + assert_redirected_to '/settings?tab=display' + + assert_equal({}, Setting.favicon) + end + end + def test_post_mail_handler_delimiters_should_not_save_invalid_regex_delimiters post :edit, :params => { :settings => { diff --git a/test/integration/lib/redmine/themes_test.rb b/test/integration/lib/redmine/themes_test.rb index 10cfac660..7fe7d3192 100644 --- a/test/integration/lib/redmine/themes_test.rb +++ b/test/integration/lib/redmine/themes_test.rb @@ -76,6 +76,19 @@ class ThemesTest < Redmine::IntegrationTest @theme.favicons.delete 'a.ico' end + def test_use_custom_favicon_over_theme_favicon + @theme.favicons.unshift('a.ico') + favicon = {'content_type' => 'image/png', 'data' => 'AA==', 'digest' => '0' * 8} + with_settings :favicon => favicon do + get '/' + + assert_response :success + assert_select 'link[rel="shortcut icon"][type="image/png"][href=?]', "/favicons/#{'0' * 8}" + end + ensure + @theme.favicons.delete 'a.ico' + end + def test_use_only_one_theme_favicon_if_theme_provides_many @theme.favicons.unshift('b.ico', 'a.png') get '/' -- 2.55.0