diff --git a/app/controllers/attachments_controller.rb b/app/controllers/attachments_controller.rb index a52024d14..4ef929896 100644 --- a/app/controllers/attachments_controller.rb +++ b/app/controllers/attachments_controller.rb @@ -17,7 +17,7 @@ class AttachmentsController < ApplicationController before_filter :find_project, :except => :upload - before_filter :file_readable, :read_authorize, :only => [:show, :download] + before_filter :file_readable, :read_authorize, :only => [:show, :download, :thumbnail] before_filter :delete_authorize, :only => :destroy before_filter :authorize_global, :only => :upload @@ -59,6 +59,18 @@ class AttachmentsController < ApplicationController end + def thumbnail + if @attachment.thumbnailable? && Setting.thumbnails_enabled? && thumbnail = @attachment.thumbnail + send_file thumbnail, + :filename => filename_for_content_disposition(@attachment.filename), + :type => detect_content_type(@attachment), + :disposition => 'inline' + else + # No thumbnail for the attachment or thumbnail could not be created + render :nothing => true, :status => 404 + end + end + def upload # Make sure that API users get used to set this content type # as it won't trigger Rails' automatic parsing of the request body for parameters diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index eaf89da8a..9963408fb 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -153,6 +153,12 @@ module ApplicationHelper end end + def thumbnail_tag(attachment) + link_to image_tag(url_for(:controller => 'attachments', :action => 'thumbnail', :id => attachment)), + {:controller => 'attachments', :action => 'show', :id => attachment, :filename => attachment.filename}, + :title => attachment.filename + end + def toggle_link(name, id, options={}) onclick = "Element.toggle('#{id}'); " onclick << (options[:focus] ? "Form.Element.focus('#{options[:focus]}'); " : "this.blur(); ") diff --git a/app/helpers/attachments_helper.rb b/app/helpers/attachments_helper.rb index 73cbaf01d..7b7bdf5da 100644 --- a/app/helpers/attachments_helper.rb +++ b/app/helpers/attachments_helper.rb @@ -21,12 +21,14 @@ module AttachmentsHelper # Displays view/delete links to the attachments of the given object # Options: # :author -- author names are not displayed if set to false + # :thumbails -- display thumbnails if enabled in settings def link_to_attachments(container, options = {}) - options.assert_valid_keys(:author) + options.assert_valid_keys(:author, :thumbnails) if container.attachments.any? options = {:deletable => container.attachments_deletable?, :author => true}.merge(options) - render :partial => 'attachments/links', :locals => {:attachments => container.attachments, :options => options} + render :partial => 'attachments/links', + :locals => {:attachments => container.attachments, :options => options, :thumbnails => (options[:thumbnails] && Setting.thumbnails_enabled?)} end end diff --git a/app/models/attachment.rb b/app/models/attachment.rb index b61db87e5..1fd0a5b82 100644 --- a/app/models/attachment.rb +++ b/app/models/attachment.rb @@ -46,6 +46,9 @@ class Attachment < ActiveRecord::Base cattr_accessor :storage_path @@storage_path = Redmine::Configuration['attachments_storage_path'] || File.join(Rails.root, "files") + cattr_accessor :thumbnails_storage_path + @@thumbnails_storage_path = File.join(Rails.root, "tmp", "thumbnails") + before_save :files_to_final_location after_destroy :delete_from_disk @@ -150,7 +153,35 @@ class Attachment < ActiveRecord::Base end def image? - self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i + !!(self.filename =~ /\.(bmp|gif|jpg|jpe|jpeg|png)$/i) + end + + def thumbnailable? + image? + end + + # Returns the full path the attachment thumbnail, or nil + # if the thumbnail cannot be generated. + def thumbnail + if thumbnailable? && readable? + size = Setting.thumbnails_size.to_i + size = 100 unless size > 0 + target = File.join(self.class.thumbnails_storage_path, "#{id}_#{digest}_#{size}.thumb") + + begin + Redmine::Thumbnail.generate(self.diskfile, target, size) + rescue => e + logger.error "An error occured while generating thumbnail for #{disk_filename} to #{target}\nException was: #{e.message}" if logger + return nil + end + end + end + + # Deletes all thumbnails + def self.clear_thumbnails + Dir.glob(File.join(thumbnails_storage_path, "*.thumb")).each do |file| + File.delete file + end end def is_text? diff --git a/app/views/attachments/_links.html.erb b/app/views/attachments/_links.html.erb index 779702fb7..f77cfb1eb 100644 --- a/app/views/attachments/_links.html.erb +++ b/app/views/attachments/_links.html.erb @@ -20,4 +20,14 @@ <% end %>

<% end %> +<% if defined?(thumbnails) && thumbnails %> + <% images = attachments.select(&:thumbnailable?) %> + <% if images.any? %> +
+ <% images.each do |attachment| %> +
<%= thumbnail_tag(attachment) %>
+ <% end %> +
+ <% end %> +<% end %> diff --git a/app/views/issues/show.html.erb b/app/views/issues/show.html.erb index 4f53592b0..5a60b0326 100644 --- a/app/views/issues/show.html.erb +++ b/app/views/issues/show.html.erb @@ -81,7 +81,7 @@ end %> <%= textilizable @issue, :description, :attachments => @issue.attachments %> <% end %> -<%= link_to_attachments @issue %> +<%= link_to_attachments @issue, :thumbnails => true %> <% end -%> <%= call_hook(:view_issues_show_description_bottom, :issue => @issue) %> diff --git a/app/views/settings/_display.html.erb b/app/views/settings/_display.html.erb index 2eb0e2560..1ae5a351a 100644 --- a/app/views/settings/_display.html.erb +++ b/app/views/settings/_display.html.erb @@ -16,6 +16,10 @@

<%= setting_check_box :gravatar_enabled %>

<%= setting_select :gravatar_default, [["Wavatars", 'wavatar'], ["Identicons", 'identicon'], ["Monster ids", 'monsterid'], ["Retro", 'retro'], ["Mystery man", 'mm']], :blank => :label_none %>

+ +

<%= setting_check_box :thumbnails_enabled %>

+ +

<%= setting_text_field :thumbnails_size, :size => 6 %>

<%= submit_tag l(:button_save) %> diff --git a/config/configuration.yml.example b/config/configuration.yml.example index 2224cd130..35f1fa4b4 100644 --- a/config/configuration.yml.example +++ b/config/configuration.yml.example @@ -163,6 +163,10 @@ default: # same secret token on each machine. #secret_token: 'change it to a long random string' + # Absolute path (e.g. /usr/bin/convert, c:/im/convert.exe) to + # the ImageMagick's `convert` binary. Used to generate attachment thumbnails. + #imagemagick_convert_command: + # specific configuration options for production environment # that overrides the default ones production: diff --git a/config/locales/en.yml b/config/locales/en.yml index 4609cfba3..ef4466c52 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -394,6 +394,8 @@ en: setting_unsubscribe: Allow users to delete their own account setting_session_lifetime: Session maximum lifetime setting_session_timeout: Session inactivity timeout + setting_thumbnails_enabled: Display attachment thumbnails + setting_thumbnails_size: Thumbnails size (in pixels) permission_add_project: Create project permission_add_subprojects: Create subprojects diff --git a/config/locales/fr.yml b/config/locales/fr.yml index ca06fac06..7a68b72c0 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -390,6 +390,8 @@ fr: setting_unsubscribe: Permettre aux utilisateurs de supprimer leur propre compte setting_session_lifetime: Durée de vie maximale des sessions setting_session_timeout: Durée maximale d'inactivité + setting_thumbnails_enabled: Afficher les vignettes des images + setting_thumbnails_size: Taille des vignettes (en pixels) permission_add_project: Créer un projet permission_add_subprojects: Créer des sous-projets diff --git a/config/routes.rb b/config/routes.rb index 1ecaba8af..b4c792b99 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -264,6 +264,7 @@ RedmineApp::Application.routes.draw do match 'attachments/:id/:filename', :controller => 'attachments', :action => 'show', :id => /\d+/, :filename => /.*/, :via => :get match 'attachments/download/:id/:filename', :controller => 'attachments', :action => 'download', :id => /\d+/, :filename => /.*/, :via => :get match 'attachments/download/:id', :controller => 'attachments', :action => 'download', :id => /\d+/, :via => :get + match 'attachments/thumbnail/:id', :controller => 'attachments', :action => 'thumbnail', :id => /\d+/, :via => :get resources :attachments, :only => [:show, :destroy] resources :groups do diff --git a/config/settings.yml b/config/settings.yml index edb3baa24..01906609f 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -212,3 +212,8 @@ default_notification_option: default: 'only_my_events' emails_header: default: '' +thumbnails_enabled: + default: 0 +thumbnails_size: + format: int + default: 100 diff --git a/lib/redmine/thumbnail.rb b/lib/redmine/thumbnail.rb new file mode 100644 index 000000000..2fa33a13f --- /dev/null +++ b/lib/redmine/thumbnail.rb @@ -0,0 +1,46 @@ +# Redmine - project management software +# Copyright (C) 2006-2012 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 'fileutils' + +module Redmine + module Thumbnail + extend Redmine::Utils::Shell + + # Generates a thumbnail for the source image to target + def self.generate(source, target, size) + unless File.exists?(target) + directory = File.dirname(target) + unless File.exists?(directory) + FileUtils.mkdir_p directory + end + bin = Redmine::Configuration['imagemagick_convert_command'] || 'convert' + size_option = "#{size}x#{size}>" + cmd = "#{shell_quote bin} #{shell_quote source} -thumbnail #{shell_quote size_option} #{shell_quote target}" + unless system(cmd) + logger.error("Creating thumbnail failed (#{$?}):\nCommand: #{cmd}") + return nil + end + end + target + end + + def self.logger + Rails.logger + end + end +end diff --git a/lib/redmine/utils.rb b/lib/redmine/utils.rb index 3e44b6359..cfdb4d15d 100644 --- a/lib/redmine/utils.rb +++ b/lib/redmine/utils.rb @@ -41,5 +41,15 @@ module Redmine SecureRandom.hex(n) end end + + module Shell + def shell_quote(str) + if Redmine::Platform.mswin? + '"' + str.gsub(/"/, '\\"') + '"' + else + "'" + str.gsub(/'/, "'\"'\"'") + "'" + end + end + end end end diff --git a/public/stylesheets/application.css b/public/stylesheets/application.css index d7aab6edd..57f2f24b6 100644 --- a/public/stylesheets/application.css +++ b/public/stylesheets/application.css @@ -516,6 +516,10 @@ div.attachments p { margin:4px 0 2px 0; } div.attachments img { vertical-align: middle; } div.attachments span.author { font-size: 0.9em; color: #888; } +div.thumbnails {margin-top:0.6em;} +div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;} +div.thumbnails img {margin: 3px;} + p.other-formats { text-align: right; font-size:0.9em; color: #666; } .other-formats span + span:before { content: "| "; } diff --git a/test/functional/attachments_controller_test.rb b/test/functional/attachments_controller_test.rb index 487ec5809..949ed3979 100644 --- a/test/functional/attachments_controller_test.rb +++ b/test/functional/attachments_controller_test.rb @@ -252,12 +252,58 @@ class AttachmentsControllerTest < ActionController::TestCase set_tmp_attachments_directory end - def test_anonymous_on_private_private + def test_download_should_be_denied_without_permission get :download, :id => 7 assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fdownload%2F7' set_tmp_attachments_directory end + if convert_installed? + def test_thumbnail + Attachment.clear_thumbnails + @request.session[:user_id] = 2 + with_settings :thumbnails_enabled => '1' do + get :thumbnail, :id => 16 + assert_response :success + assert_equal 'image/png', response.content_type + end + end + + def test_thumbnail_should_return_404_for_non_image_attachment + @request.session[:user_id] = 2 + with_settings :thumbnails_enabled => '1' do + get :thumbnail, :id => 15 + assert_response 404 + end + end + + def test_thumbnail_should_return_404_if_thumbnails_not_enabled + @request.session[:user_id] = 2 + with_settings :thumbnails_enabled => '0' do + get :thumbnail, :id => 16 + assert_response 404 + end + end + + def test_thumbnail_should_return_404_if_thumbnail_generation_failed + Attachment.any_instance.stubs(:thumbnail).returns(nil) + @request.session[:user_id] = 2 + with_settings :thumbnails_enabled => '1' do + get :thumbnail, :id => 16 + assert_response 404 + end + end + + def test_thumbnail_should_be_denied_without_permission + with_settings :thumbnails_enabled => '1' do + get :thumbnail, :id => 16 + assert_redirected_to '/login?back_url=http%3A%2F%2Ftest.host%2Fattachments%2Fthumbnail%2F16' + end + end + else + puts '(ImageMagick convert not available)' + end + def test_destroy_issue_attachment set_tmp_attachments_directory issue = Issue.find(3) diff --git a/test/functional/issues_controller_test.rb b/test/functional/issues_controller_test.rb index d5c2565a1..7e9e3a398 100644 --- a/test/functional/issues_controller_test.rb +++ b/test/functional/issues_controller_test.rb @@ -1155,7 +1155,33 @@ class IssuesControllerTest < ActionController::TestCase end end end - + + def test_show_with_thumbnails_enabled_should_display_thumbnails + @request.session[:user_id] = 2 + + with_settings :thumbnails_enabled => '1' do + get :show, :id => 14 + assert_response :success + end + + assert_select 'div.thumbnails' do + assert_select 'a[href=/attachments/16/testfile.png]' do + assert_select 'img[src=/attachments/thumbnail/16]' + end + end + end + + def test_show_with_thumbnails_disabled_should_not_display_thumbnails + @request.session[:user_id] = 2 + + with_settings :thumbnails_enabled => '0' do + get :show, :id => 14 + assert_response :success + end + + assert_select 'div.thumbnails', 0 + end + def test_show_with_multi_custom_field field = CustomField.find(1) field.update_attribute :multiple, true diff --git a/test/integration/routing/attachments_test.rb b/test/integration/routing/attachments_test.rb index 84ccbbb67..ba4bb2c36 100644 --- a/test/integration/routing/attachments_test.rb +++ b/test/integration/routing/attachments_test.rb @@ -45,6 +45,10 @@ class RoutingAttachmentsTest < ActionController::IntegrationTest { :controller => 'attachments', :action => 'download', :id => '1', :filename => 'filename.ext' } ) + assert_routing( + { :method => 'get', :path => "/attachments/thumbnail/1" }, + { :controller => 'attachments', :action => 'thumbnail', :id => '1' } + ) assert_routing( { :method => 'delete', :path => "/attachments/1" }, { :controller => 'attachments', :action => 'destroy', :id => '1' } diff --git a/test/test_helper.rb b/test/test_helper.rb index 4485f75c9..6f5ab98a4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -128,6 +128,13 @@ class ActiveSupport::TestCase return nil end + def self.convert_installed? + bin = Redmine::Configuration['imagemagick_convert_command'] || 'convert' + system("#{bin} -version") + rescue + false + end + # Returns the path to the test +vendor+ repository def self.repository_path(vendor) Rails.root.join("tmp/test/#{vendor.downcase}_repository").to_s diff --git a/test/unit/attachment_test.rb b/test/unit/attachment_test.rb index 9d78077fd..cd301dc93 100644 --- a/test/unit/attachment_test.rb +++ b/test/unit/attachment_test.rb @@ -214,4 +214,28 @@ class AttachmentTest < ActiveSupport::TestCase set_tmp_attachments_directory end + + def test_thumbnailable_should_be_true_for_images + assert_equal true, Attachment.new(:filename => 'test.jpg').thumbnailable? + end + + def test_thumbnailable_should_be_true_for_non_images + assert_equal false, Attachment.new(:filename => 'test.txt').thumbnailable? + end + + if convert_installed? + def test_thumbnail_should_generate_the_thumbnail + set_fixtures_attachments_directory + attachment = Attachment.find(16) + Attachment.clear_thumbnails + + assert_difference "Dir.glob(File.join(Attachment.thumbnails_storage_path, '*.thumb')).size" do + thumbnail = attachment.thumbnail + assert_equal "16_8e0294de2441577c529f170b6fb8f638_100.thumb", File.basename(thumbnail) + assert File.exists?(thumbnail) + end + end + else + puts '(ImageMagick convert not available)' + end end