mirror of
https://github.com/redmine/redmine.git
synced 2025-11-07 13:55:52 +01:00
Download all attachments at once (#7056).
Patch by Mizuki ISHIKAWA. git-svn-id: http://svn.redmine.org/redmine/trunk@19601 e93f8b46-1217-0410-a6f0-8f06a7374b81
This commit is contained in:
1
Gemfile
1
Gemfile
@@ -17,6 +17,7 @@ gem "nokogiri", "~> 1.10.0"
|
|||||||
gem 'i18n', '~> 1.8.2'
|
gem 'i18n', '~> 1.8.2'
|
||||||
gem "rbpdf", "~> 1.20.0"
|
gem "rbpdf", "~> 1.20.0"
|
||||||
gem 'addressable'
|
gem 'addressable'
|
||||||
|
gem 'rubyzip', (RUBY_VERSION < '2.4' ? '~> 1.3.0' : '~> 2.2.0')
|
||||||
|
|
||||||
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem
|
||||||
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
|
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin]
|
||||||
|
|||||||
@@ -19,6 +19,8 @@
|
|||||||
|
|
||||||
class AttachmentsController < ApplicationController
|
class AttachmentsController < ApplicationController
|
||||||
before_action :find_attachment, :only => [:show, :download, :thumbnail, :update, :destroy]
|
before_action :find_attachment, :only => [:show, :download, :thumbnail, :update, :destroy]
|
||||||
|
before_action :find_container, :only => [:edit_all, :update_all, :download_all]
|
||||||
|
before_action :find_downloadable_attachments, :only => :download_all
|
||||||
before_action :find_editable_attachments, :only => [:edit_all, :update_all]
|
before_action :find_editable_attachments, :only => [:edit_all, :update_all]
|
||||||
before_action :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
|
before_action :file_readable, :read_authorize, :only => [:show, :download, :thumbnail]
|
||||||
before_action :update_authorize, :only => :update
|
before_action :update_authorize, :only => :update
|
||||||
@@ -132,6 +134,20 @@ class AttachmentsController < ApplicationController
|
|||||||
render :action => 'edit_all'
|
render :action => 'edit_all'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def download_all
|
||||||
|
Tempfile.create('attachments_zip-', Rails.root.join('tmp')) do |tempfile|
|
||||||
|
zip_file = Attachment.archive_attachments(tempfile, @attachments)
|
||||||
|
if zip_file
|
||||||
|
send_data(
|
||||||
|
File.read(zip_file.path),
|
||||||
|
:type => 'application/zip',
|
||||||
|
:filename => "#{@container.class.to_s.downcase}-#{@container.id}-attachments.zip")
|
||||||
|
else
|
||||||
|
render_404
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def update
|
def update
|
||||||
@attachment.safe_attributes = params[:attachment]
|
@attachment.safe_attributes = params[:attachment]
|
||||||
saved = @attachment.save
|
saved = @attachment.save
|
||||||
@@ -195,6 +211,11 @@ class AttachmentsController < ApplicationController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def find_editable_attachments
|
def find_editable_attachments
|
||||||
|
@attachments = @container.attachments.select(&:editable?)
|
||||||
|
render_404 if @attachments.empty?
|
||||||
|
end
|
||||||
|
|
||||||
|
def find_container
|
||||||
klass = params[:object_type].to_s.singularize.classify.constantize rescue nil
|
klass = params[:object_type].to_s.singularize.classify.constantize rescue nil
|
||||||
unless klass && klass.reflect_on_association(:attachments)
|
unless klass && klass.reflect_on_association(:attachments)
|
||||||
render_404
|
render_404
|
||||||
@@ -206,15 +227,24 @@ class AttachmentsController < ApplicationController
|
|||||||
render_403
|
render_403
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
@attachments = @container.attachments.select(&:editable?)
|
|
||||||
if @container.respond_to?(:project)
|
if @container.respond_to?(:project)
|
||||||
@project = @container.project
|
@project = @container.project
|
||||||
end
|
end
|
||||||
render_404 if @attachments.empty?
|
|
||||||
rescue ActiveRecord::RecordNotFound
|
rescue ActiveRecord::RecordNotFound
|
||||||
render_404
|
render_404
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def find_downloadable_attachments
|
||||||
|
@attachments = @container.attachments.select{|a| File.readable?(a.diskfile) }
|
||||||
|
bulk_download_max_size = Setting.bulk_download_max_size.to_i.kilobytes
|
||||||
|
if @attachments.sum(&:filesize) > bulk_download_max_size
|
||||||
|
flash[:error] = l(:error_bulk_download_size_too_big,
|
||||||
|
:max_size => bulk_download_max_size.to_i.kilobytes)
|
||||||
|
redirect_to back_url
|
||||||
|
return
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
# Checks that the file exists and is readable
|
# Checks that the file exists and is readable
|
||||||
def file_readable
|
def file_readable
|
||||||
if @attachment.readable?
|
if @attachment.readable?
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ module AttachmentsHelper
|
|||||||
object_attachments_path container.class.name.underscore.pluralize, container.id
|
object_attachments_path container.class.name.underscore.pluralize, container.id
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def container_attachments_download_path(container)
|
||||||
|
object_attachments_download_path container.class.name.underscore.pluralize, container.id
|
||||||
|
end
|
||||||
|
|
||||||
# Displays view/delete links to the attachments of the given object
|
# Displays view/delete links to the attachments of the given object
|
||||||
# Options:
|
# Options:
|
||||||
# :author -- author names are not displayed if set to false
|
# :author -- author names are not displayed if set to false
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
require "digest"
|
require "digest"
|
||||||
require "fileutils"
|
require "fileutils"
|
||||||
|
require "zip"
|
||||||
|
|
||||||
class Attachment < ActiveRecord::Base
|
class Attachment < ActiveRecord::Base
|
||||||
include Redmine::SafeAttributes
|
include Redmine::SafeAttributes
|
||||||
@@ -345,6 +346,30 @@ class Attachment < ActiveRecord::Base
|
|||||||
Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
|
Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def self.archive_attachments(out_file, attachments)
|
||||||
|
attachments = attachments.select{|attachment| File.readable?(attachment.diskfile) }
|
||||||
|
return nil if attachments.blank?
|
||||||
|
|
||||||
|
Zip.unicode_names = true
|
||||||
|
archived_file_names = []
|
||||||
|
Zip::File.open(out_file.path, Zip::File::CREATE) do |zip|
|
||||||
|
attachments.each do |attachment|
|
||||||
|
filename = attachment.filename
|
||||||
|
# rename the file if a file with the same name already exists
|
||||||
|
dup_count = 0
|
||||||
|
while archived_file_names.include?(filename)
|
||||||
|
dup_count += 1
|
||||||
|
basename = File.basename(attachment.filename, '.*')
|
||||||
|
extname = File.extname(attachment.filename)
|
||||||
|
filename = "#{basename}(#{dup_count})#{extname}"
|
||||||
|
end
|
||||||
|
zip.add(filename, attachment.diskfile)
|
||||||
|
archived_file_names << filename
|
||||||
|
end
|
||||||
|
end
|
||||||
|
out_file
|
||||||
|
end
|
||||||
|
|
||||||
# Moves an existing attachment to its target directory
|
# Moves an existing attachment to its target directory
|
||||||
def move_to_target_directory!
|
def move_to_target_directory!
|
||||||
return unless !new_record? & readable?
|
return unless !new_record? & readable?
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
:title => l(:label_edit_attachments),
|
:title => l(:label_edit_attachments),
|
||||||
:class => 'icon-only icon-edit'
|
:class => 'icon-only icon-edit'
|
||||||
) if options[:editable] %>
|
) if options[:editable] %>
|
||||||
|
<%= link_to(l(:label_download_all_attachments),
|
||||||
|
container_attachments_download_path(container),
|
||||||
|
:title => l(:label_download_all_attachments),
|
||||||
|
:class => 'icon-only icon-download'
|
||||||
|
) if attachments.size > 1 %>
|
||||||
</div>
|
</div>
|
||||||
<table>
|
<table>
|
||||||
<% for attachment in attachments %>
|
<% for attachment in attachments %>
|
||||||
|
|||||||
@@ -3,6 +3,8 @@
|
|||||||
<div class="box tabular settings">
|
<div class="box tabular settings">
|
||||||
<p><%= setting_text_field :attachment_max_size, :size => 6 %> <%= l(:"number.human.storage_units.units.kb") %></p>
|
<p><%= setting_text_field :attachment_max_size, :size => 6 %> <%= l(:"number.human.storage_units.units.kb") %></p>
|
||||||
|
|
||||||
|
<p><%= setting_text_field :bulk_download_max_size, :size => 6 %> <%= l(:"number.human.storage_units.units.kb") %></p>
|
||||||
|
|
||||||
<p><%= setting_text_area :attachment_extensions_allowed %>
|
<p><%= setting_text_area :attachment_extensions_allowed %>
|
||||||
<em class="info"><%= l(:text_comma_separated) %> <%= l(:label_example) %>: txt, png</em></p>
|
<em class="info"><%= l(:text_comma_separated) %> <%= l(:label_example) %>: txt, png</em></p>
|
||||||
|
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ en:
|
|||||||
error_unable_delete_issue_status: 'Unable to delete issue status (%{value})'
|
error_unable_delete_issue_status: 'Unable to delete issue status (%{value})'
|
||||||
error_unable_to_connect: "Unable to connect (%{value})"
|
error_unable_to_connect: "Unable to connect (%{value})"
|
||||||
error_attachment_too_big: "This file cannot be uploaded because it exceeds the maximum allowed file size (%{max_size})"
|
error_attachment_too_big: "This file cannot be uploaded because it exceeds the maximum allowed file size (%{max_size})"
|
||||||
|
error_bulk_download_size_too_big: "These attachments cannot be bulk downloaded because the total file size exceeds the maximum allowed size (%{max_size})"
|
||||||
error_session_expired: "Your session has expired. Please login again."
|
error_session_expired: "Your session has expired. Please login again."
|
||||||
error_token_expired: "This password recovery link has expired, please try again."
|
error_token_expired: "This password recovery link has expired, please try again."
|
||||||
warning_attachments_not_saved: "%{count} file(s) could not be saved."
|
warning_attachments_not_saved: "%{count} file(s) could not be saved."
|
||||||
@@ -401,6 +402,7 @@ en:
|
|||||||
setting_self_registration: Self-registration
|
setting_self_registration: Self-registration
|
||||||
setting_show_custom_fields_on_registration: Show custom fields on registration
|
setting_show_custom_fields_on_registration: Show custom fields on registration
|
||||||
setting_attachment_max_size: Maximum attachment size
|
setting_attachment_max_size: Maximum attachment size
|
||||||
|
setting_bulk_download_max_size: Maximum total size for bulk download
|
||||||
setting_issues_export_limit: Issues export limit
|
setting_issues_export_limit: Issues export limit
|
||||||
setting_mail_from: Emission email address
|
setting_mail_from: Emission email address
|
||||||
setting_bcc_recipients: Blind carbon copy recipients (bcc)
|
setting_bcc_recipients: Blind carbon copy recipients (bcc)
|
||||||
@@ -1018,6 +1020,7 @@ en:
|
|||||||
label_users_visibility_all: All active users
|
label_users_visibility_all: All active users
|
||||||
label_users_visibility_members_of_visible_projects: Members of visible projects
|
label_users_visibility_members_of_visible_projects: Members of visible projects
|
||||||
label_edit_attachments: Edit attached files
|
label_edit_attachments: Edit attached files
|
||||||
|
label_download_all_attachments: Download all files
|
||||||
label_link_copied_issue: Link copied issue
|
label_link_copied_issue: Link copied issue
|
||||||
label_ask: Ask
|
label_ask: Ask
|
||||||
label_search_attachments_yes: Search attachment filenames and descriptions
|
label_search_attachments_yes: Search attachment filenames and descriptions
|
||||||
|
|||||||
@@ -289,6 +289,7 @@ Rails.application.routes.draw do
|
|||||||
resources :attachments, :only => [:show, :update, :destroy]
|
resources :attachments, :only => [:show, :update, :destroy]
|
||||||
get 'attachments/:object_type/:object_id/edit', :to => 'attachments#edit_all', :as => :object_attachments_edit
|
get 'attachments/:object_type/:object_id/edit', :to => 'attachments#edit_all', :as => :object_attachments_edit
|
||||||
patch 'attachments/:object_type/:object_id', :to => 'attachments#update_all', :as => :object_attachments
|
patch 'attachments/:object_type/:object_id', :to => 'attachments#update_all', :as => :object_attachments
|
||||||
|
get 'attachments/:object_type/:object_id/download', :to => 'attachments#download_all', :as => :object_attachments_download
|
||||||
|
|
||||||
resources :groups do
|
resources :groups do
|
||||||
resources :memberships, :controller => 'principal_memberships'
|
resources :memberships, :controller => 'principal_memberships'
|
||||||
|
|||||||
@@ -66,6 +66,9 @@ session_timeout:
|
|||||||
attachment_max_size:
|
attachment_max_size:
|
||||||
format: int
|
format: int
|
||||||
default: 5120
|
default: 5120
|
||||||
|
bulk_download_max_size:
|
||||||
|
format: int
|
||||||
|
default: 102400
|
||||||
attachment_extensions_allowed:
|
attachment_extensions_allowed:
|
||||||
default:
|
default:
|
||||||
attachment_extensions_denied:
|
attachment_extensions_denied:
|
||||||
|
|||||||
@@ -577,6 +577,50 @@ class AttachmentsControllerTest < Redmine::ControllerTest
|
|||||||
assert_equal 'This is a Ruby source file', attachment.description
|
assert_equal 'This is a Ruby source file', attachment.description
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_download_all_with_valid_container
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
get :download_all, :params => {
|
||||||
|
:object_type => 'issues',
|
||||||
|
:object_id => '2'
|
||||||
|
}
|
||||||
|
assert_response 200
|
||||||
|
assert_equal response.headers['Content-Type'], 'application/zip'
|
||||||
|
assert_match /issue-2-attachments.zip/, response.headers['Content-Disposition']
|
||||||
|
assert_not_includes Dir.entries(Rails.root.join('tmp')), /attachments_zip/
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_download_all_with_invalid_container
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
get :download_all, :params => {
|
||||||
|
:object_type => 'issues',
|
||||||
|
:object_id => '999'
|
||||||
|
}
|
||||||
|
assert_response 404
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_download_all_without_readable_attachments
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
get :download_all, :params => {
|
||||||
|
:object_type => 'issues',
|
||||||
|
:object_id => '1'
|
||||||
|
}
|
||||||
|
assert_equal Issue.find(1).attachments, []
|
||||||
|
assert_response 404
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_download_all_with_maximum_bulk_download_size_larger_than_attachments
|
||||||
|
with_settings :bulk_download_max_size => 0 do
|
||||||
|
@request.session[:user_id] = 2
|
||||||
|
get :download_all, :params => {
|
||||||
|
:object_type => 'issues',
|
||||||
|
:object_id => '2',
|
||||||
|
:back_url => '/issues/2'
|
||||||
|
}
|
||||||
|
assert_redirected_to '/issues/2'
|
||||||
|
assert_equal flash[:error], 'These attachments cannot be bulk downloaded because the total file size exceeds the maximum allowed size (0)'
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def test_destroy_issue_attachment
|
def test_destroy_issue_attachment
|
||||||
set_tmp_attachments_directory
|
set_tmp_attachments_directory
|
||||||
issue = Issue.find(3)
|
issue = Issue.find(3)
|
||||||
|
|||||||
@@ -35,5 +35,6 @@ class RoutingAttachmentsTest < Redmine::RoutingTest
|
|||||||
|
|
||||||
should_route 'GET /attachments/issues/1/edit' => 'attachments#edit_all', :object_type => 'issues', :object_id => '1'
|
should_route 'GET /attachments/issues/1/edit' => 'attachments#edit_all', :object_type => 'issues', :object_id => '1'
|
||||||
should_route 'PATCH /attachments/issues/1' => 'attachments#update_all', :object_type => 'issues', :object_id => '1'
|
should_route 'PATCH /attachments/issues/1' => 'attachments#update_all', :object_type => 'issues', :object_id => '1'
|
||||||
|
should_route 'GET /attachments/issues/1/download' => 'attachments#download_all', :object_type => 'issues', :object_id => '1'
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -278,6 +278,32 @@ class AttachmentTest < ActiveSupport::TestCase
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def test_archive_attachments
|
||||||
|
attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
|
||||||
|
Tempfile.create('attachments_zip', Rails.root.join('tmp')) do |tempfile|
|
||||||
|
zip_file = Attachment.archive_attachments(tempfile, [attachment])
|
||||||
|
assert_instance_of File, zip_file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_archive_attachments_without_attachments
|
||||||
|
Tempfile.create('attachments_zip', Rails.root.join('tmp')) do |tempfile|
|
||||||
|
zip_file = Attachment.archive_attachments(tempfile, [])
|
||||||
|
assert_nil zip_file
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def test_archive_attachments_should_rename_duplicate_file_names
|
||||||
|
attachment1 = Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
|
||||||
|
attachment2 = Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
|
||||||
|
Tempfile.create('attachments_zip', Rails.root.join('tmp')) do |tempfile|
|
||||||
|
zip_file = Attachment.archive_attachments(tempfile, [attachment1, attachment2])
|
||||||
|
Zip::File.open(zip_file.path) do |z|
|
||||||
|
assert_equal ['testfile.txt', 'testfile(1).txt'], z.map(&:name)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
def test_move_from_root_to_target_directory_should_move_root_files
|
def test_move_from_root_to_target_directory_should_move_root_files
|
||||||
a = Attachment.find(20)
|
a = Attachment.find(20)
|
||||||
assert a.disk_directory.blank?
|
assert a.disk_directory.blank?
|
||||||
|
|||||||
Reference in New Issue
Block a user