Feature #7056 » compress_the_all_attachments_in_issue_v4.patch
| Gemfile | ||
|---|---|---|
| 17 | 17 |
gem 'i18n', '~> 1.8.2' |
| 18 | 18 |
gem "rbpdf", "~> 1.20.0" |
| 19 | 19 |
gem 'addressable' |
| 20 |
gem 'rubyzip', '~> 2.2.0' |
|
| 20 | 21 | |
| 21 | 22 |
# Windows does not include zoneinfo files, so bundle the tzinfo-data gem |
| 22 | 23 |
gem 'tzinfo-data', platforms: [:mingw, :x64_mingw, :mswin] |
| app/controllers/attachments_controller.rb | ||
|---|---|---|
| 19 | 19 | |
| 20 | 20 |
class AttachmentsController < ApplicationController |
| 21 | 21 |
before_action :find_attachment, :only => [:show, :download, :thumbnail, :update, :destroy] |
| 22 |
before_action :find_container, :only => [:edit_all, :update_all, :download_all] |
|
| 23 |
before_action :find_downloadable_attachments, :only => :download_all |
|
| 22 | 24 |
before_action :find_editable_attachments, :only => [:edit_all, :update_all] |
| 23 | 25 |
before_action :file_readable, :read_authorize, :only => [:show, :download, :thumbnail] |
| 24 | 26 |
before_action :update_authorize, :only => :update |
| ... | ... | |
| 132 | 134 |
render :action => 'edit_all' |
| 133 | 135 |
end |
| 134 | 136 | |
| 137 |
def download_all |
|
| 138 |
Tempfile.create('attachments_zip', Rails.root.join('tmp')) do |tempfile|
|
|
| 139 |
zip_file = Attachment.attachments_to_zip(tempfile, @attachments) |
|
| 140 |
if zip_file.nil? |
|
| 141 |
render_404 |
|
| 142 |
return |
|
| 143 |
end |
|
| 144 |
send_data(File.read(zip_file.path), :type => 'application/zip', |
|
| 145 |
:filename => "#{@container.class.to_s.downcase}-#{@container.id}-attachments.zip")
|
|
| 146 |
end |
|
| 147 |
end |
|
| 148 | ||
| 135 | 149 |
def update |
| 136 | 150 |
@attachment.safe_attributes = params[:attachment] |
| 137 | 151 |
saved = @attachment.save |
| ... | ... | |
| 195 | 209 |
end |
| 196 | 210 | |
| 197 | 211 |
def find_editable_attachments |
| 212 |
@attachments = @container.attachments.select(&:editable?) |
|
| 213 |
render_404 if @attachments.empty? |
|
| 214 |
end |
|
| 215 | ||
| 216 |
def find_container |
|
| 198 | 217 |
klass = params[:object_type].to_s.singularize.classify.constantize rescue nil |
| 199 | 218 |
unless klass && klass.reflect_on_association(:attachments) |
| 200 | 219 |
render_404 |
| ... | ... | |
| 206 | 225 |
render_403 |
| 207 | 226 |
return |
| 208 | 227 |
end |
| 209 |
@attachments = @container.attachments.select(&:editable?) |
|
| 210 | 228 |
if @container.respond_to?(:project) |
| 211 | 229 |
@project = @container.project |
| 212 | 230 |
end |
| 213 |
render_404 if @attachments.empty? |
|
| 214 | 231 |
rescue ActiveRecord::RecordNotFound |
| 215 | 232 |
render_404 |
| 216 | 233 |
end |
| 217 | 234 | |
| 235 |
def find_downloadable_attachments |
|
| 236 |
@attachments = @container.attachments.select{|a| File.readable?(a.diskfile) }
|
|
| 237 |
bulk_download_max_size = Redmine::Configuration['bulk_download_max_size'].to_i.kilobytes |
|
| 238 |
if @attachments.sum(&:filesize) > bulk_download_max_size |
|
| 239 |
flash[:error] = l(:error_bulk_download_size_too_big, |
|
| 240 |
:max_size => bulk_download_max_size) |
|
| 241 |
redirect_to back_url |
|
| 242 |
return |
|
| 243 |
end |
|
| 244 |
end |
|
| 245 | ||
| 218 | 246 |
# Checks that the file exists and is readable |
| 219 | 247 |
def file_readable |
| 220 | 248 |
if @attachment.readable? |
| app/helpers/attachments_helper.rb | ||
|---|---|---|
| 27 | 27 |
object_attachments_path container.class.name.underscore.pluralize, container.id |
| 28 | 28 |
end |
| 29 | 29 | |
| 30 |
def container_attachments_download_path(container) |
|
| 31 |
object_attachments_download_path container.class.name.underscore.pluralize, container.id |
|
| 32 |
end |
|
| 33 | ||
| 30 | 34 |
# Displays view/delete links to the attachments of the given object |
| 31 | 35 |
# Options: |
| 32 | 36 |
# :author -- author names are not displayed if set to false |
| app/models/attachment.rb | ||
|---|---|---|
| 19 | 19 | |
| 20 | 20 |
require "digest" |
| 21 | 21 |
require "fileutils" |
| 22 |
require "zip" |
|
| 22 | 23 | |
| 23 | 24 |
class Attachment < ActiveRecord::Base |
| 24 | 25 |
include Redmine::SafeAttributes |
| ... | ... | |
| 345 | 346 |
Attachment.where("created_on < ? AND (container_type IS NULL OR container_type = '')", Time.now - age).destroy_all
|
| 346 | 347 |
end |
| 347 | 348 | |
| 349 |
def self.attachments_to_zip(tmpfile, attachments) |
|
| 350 |
attachments = attachments.select{|attachment| File.readable?(attachment.diskfile) }
|
|
| 351 |
return nil if attachments.blank? |
|
| 352 |
Zip.unicode_names = true |
|
| 353 |
existing_file_names = [] |
|
| 354 |
Zip::File.open(tmpfile.path, Zip::File::CREATE) do |zip| |
|
| 355 |
attachments.each do |attachment| |
|
| 356 |
filename = attachment.filename |
|
| 357 |
existing_file_names << filename |
|
| 358 |
# If a file with the same name already exists, change the file name. |
|
| 359 |
if existing_file_names.count(filename) > 1 |
|
| 360 |
filename = "#{File.basename(filename, ".*")}(#{existing_file_names.count(filename)})#{File.extname(filename)}"
|
|
| 361 |
end |
|
| 362 |
zip.add(filename, attachment.diskfile) |
|
| 363 |
end |
|
| 364 |
end |
|
| 365 |
tmpfile |
|
| 366 |
end |
|
| 367 | ||
| 348 | 368 |
# Moves an existing attachment to its target directory |
| 349 | 369 |
def move_to_target_directory! |
| 350 | 370 |
return unless !new_record? & readable? |
| app/views/attachments/_links.html.erb | ||
|---|---|---|
| 42 | 42 |
</div> |
| 43 | 43 |
<% end %> |
| 44 | 44 |
<% end %> |
| 45 |
<% if attachments.size > 1 %> |
|
| 46 |
<div class="bulk-download"> |
|
| 47 |
<%= link_to(l(:label_download_all_attachments), |
|
| 48 |
container_attachments_download_path(container), |
|
| 49 |
:title => l(:label_download_all_attachments), |
|
| 50 |
:class => 'icon icon-download' |
|
| 51 |
) %> |
|
| 52 |
</div> |
|
| 53 |
<% end %> |
|
| 45 | 54 |
</div> |
| config/configuration.yml.example | ||
|---|---|---|
| 219 | 219 |
#avatar_server_url: https://www.gravatar.com # default |
| 220 | 220 |
#avatar_server_url: https://seccdn.libravatar.org |
| 221 | 221 | |
| 222 |
# Upper limit of the total file size when bulk downloading attached files (KB) |
|
| 223 |
# bulk_download_max_size: 512000 |
|
| 224 | ||
| 222 | 225 |
# specific configuration options for production environment |
| 223 | 226 |
# that overrides the default ones |
| 224 | 227 |
production: |
| config/locales/en.yml | ||
|---|---|---|
| 211 | 211 |
error_unable_delete_issue_status: 'Unable to delete issue status (%{value})'
|
| 212 | 212 |
error_unable_to_connect: "Unable to connect (%{value})"
|
| 213 | 213 |
error_attachment_too_big: "This file cannot be uploaded because it exceeds the maximum allowed file size (%{max_size})"
|
| 214 |
error_bulk_download_size_too_big: "These attachments cannot be bulk downloaded because the total file size exceeds the maximum allowed size (%{max_size})"
|
|
| 214 | 215 |
error_session_expired: "Your session has expired. Please login again." |
| 215 | 216 |
error_token_expired: "This password recovery link has expired, please try again." |
| 216 | 217 |
warning_attachments_not_saved: "%{count} file(s) could not be saved."
|
| ... | ... | |
| 1015 | 1016 |
label_users_visibility_all: All active users |
| 1016 | 1017 |
label_users_visibility_members_of_visible_projects: Members of visible projects |
| 1017 | 1018 |
label_edit_attachments: Edit attached files |
| 1019 |
label_download_all_attachments: Download all attached files |
|
| 1018 | 1020 |
label_link_copied_issue: Link copied issue |
| 1019 | 1021 |
label_ask: Ask |
| 1020 | 1022 |
label_search_attachments_yes: Search attachment filenames and descriptions |
| config/locales/ja.yml | ||
|---|---|---|
| 1016 | 1016 |
button_export: エクスポート |
| 1017 | 1017 |
label_export_options: "%{export_format} エクスポート設定"
|
| 1018 | 1018 |
error_attachment_too_big: このファイルはアップロードできません。添付ファイルサイズの上限(%{max_size})を超えています。
|
| 1019 |
error_bulk_download_size_too_big: これらの添付ファイルをダウンロードできません。一括ダウンロードサイズの上限(%{max_size})を超えています。
|
|
| 1019 | 1020 |
notice_failed_to_save_time_entries: "全%{total}件中%{count}件の作業時間が保存できませんでした: %{ids}。"
|
| 1020 | 1021 |
label_x_issues: |
| 1021 | 1022 |
zero: 0 チケット |
| config/routes.rb | ||
|---|---|---|
| 289 | 289 |
resources :attachments, :only => [:show, :update, :destroy] |
| 290 | 290 |
get 'attachments/:object_type/:object_id/edit', :to => 'attachments#edit_all', :as => :object_attachments_edit |
| 291 | 291 |
patch 'attachments/:object_type/:object_id', :to => 'attachments#update_all', :as => :object_attachments |
| 292 |
get 'attachments/:object_type/:object_id/download', :to => 'attachments#download_all', :as => :object_attachments_download |
|
| 292 | 293 | |
| 293 | 294 |
resources :groups do |
| 294 | 295 |
resources :memberships, :controller => 'principal_memberships' |
| lib/redmine/configuration.rb | ||
|---|---|---|
| 24 | 24 |
@defaults = {
|
| 25 | 25 |
'avatar_server_url' => 'https://www.gravatar.com', |
| 26 | 26 |
'email_delivery' => nil, |
| 27 |
'max_concurrent_ajax_uploads' => 2 |
|
| 27 |
'max_concurrent_ajax_uploads' => 2, |
|
| 28 |
'bulk_download_max_size' => 512000 |
|
| 28 | 29 |
} |
| 29 | 30 | |
| 30 | 31 |
@config = nil |
| public/stylesheets/application.css | ||
|---|---|---|
| 899 | 899 |
div.attachments img { vertical-align: middle; }
|
| 900 | 900 |
div.attachments span.author { font-size: 0.9em; color: #888; }
|
| 901 | 901 | |
| 902 |
div.bulk-download { margin-top: 1em; margin-left: 0.3em; margin-bottom: 0.4em;}
|
|
| 903 | ||
| 902 | 904 |
div.thumbnails {margin:0.6em;}
|
| 903 | 905 |
div.thumbnails div {background:#fff;border:2px solid #ddd;display:inline-block;margin-right:2px;}
|
| 904 | 906 |
div.thumbnails img {margin: 3px; vertical-align: middle;}
|
| test/functional/attachments_controller_test.rb | ||
|---|---|---|
| 577 | 577 |
assert_equal 'This is a Ruby source file', attachment.description |
| 578 | 578 |
end |
| 579 | 579 | |
| 580 |
def test_download_all_with_valid_container |
|
| 581 |
@request.session[:user_id] = 2 |
|
| 582 |
get :download_all, :params => {
|
|
| 583 |
:object_type => 'issues', |
|
| 584 |
:object_id => '2' |
|
| 585 |
} |
|
| 586 |
assert_response 200 |
|
| 587 |
assert_equal response.headers['Content-Type'], 'application/zip' |
|
| 588 |
assert_match (/issue-2-attachments.zip/), response.headers['Content-Disposition'] |
|
| 589 |
assert_not_includes Dir.entries(Rails.root.join('tmp')), /attachments_zip/
|
|
| 590 |
end |
|
| 591 | ||
| 592 |
def test_download_all_with_invalid_container |
|
| 593 |
@request.session[:user_id] = 2 |
|
| 594 |
get :download_all, :params => {
|
|
| 595 |
:object_type => 'issues', |
|
| 596 |
:object_id => '999' |
|
| 597 |
} |
|
| 598 |
assert_response 404 |
|
| 599 |
end |
|
| 600 | ||
| 601 |
def test_download_all_without_readable_attachments |
|
| 602 |
@request.session[:user_id] = 2 |
|
| 603 |
get :download_all, :params => {
|
|
| 604 |
:object_type => 'issues', |
|
| 605 |
:object_id => '1' |
|
| 606 |
} |
|
| 607 |
assert_equal Issue.find(1).attachments, [] |
|
| 608 |
assert_response 404 |
|
| 609 |
end |
|
| 610 | ||
| 611 |
def test_download_all_with_maximum_bulk_download_size_larger_than_attachments |
|
| 612 |
Redmine::Configuration.with 'bulk_download_max_size' => 0 do |
|
| 613 |
@request.session[:user_id] = 2 |
|
| 614 |
get :download_all, :params => {
|
|
| 615 |
:object_type => 'issues', |
|
| 616 |
:object_id => '2', |
|
| 617 |
:back_url => '/issues/2' |
|
| 618 |
} |
|
| 619 |
assert_redirected_to '/issues/2' |
|
| 620 |
assert_equal flash[:error], 'These attachments cannot be bulk downloaded because the total file size exceeds the maximum allowed size (0)' |
|
| 621 |
end |
|
| 622 |
end |
|
| 623 | ||
| 580 | 624 |
def test_destroy_issue_attachment |
| 581 | 625 |
set_tmp_attachments_directory |
| 582 | 626 |
issue = Issue.find(3) |
| test/unit/attachment_test.rb | ||
|---|---|---|
| 278 | 278 |
end |
| 279 | 279 |
end |
| 280 | 280 | |
| 281 |
def test_attachments_to_zip_with_attachments |
|
| 282 |
attachment = Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
|
|
| 283 |
Tempfile.create('attachments_zip', Rails.root.join('tmp')) do |tempfile|
|
|
| 284 |
zip_file = Attachment.attachments_to_zip(tempfile, [attachment]) |
|
| 285 |
assert_instance_of File, zip_file |
|
| 286 |
end |
|
| 287 |
end |
|
| 288 | ||
| 289 |
def test_attachments_to_zip_without_attachments |
|
| 290 |
Tempfile.create('attachments_zip', Rails.root.join('tmp')) do |tempfile|
|
|
| 291 |
zip_file = Attachment.attachments_to_zip(tempfile, []) |
|
| 292 |
assert_nil zip_file |
|
| 293 |
end |
|
| 294 |
end |
|
| 295 | ||
| 296 |
def test_attachments_to_zip_should_not_duplicate_file_names |
|
| 297 |
attachment_1 = Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
|
|
| 298 |
attachment_2 = Attachment.create!(:file => uploaded_test_file("testfile.txt", ""), :author_id => 1)
|
|
| 299 |
Tempfile.create('attachments_zip', Rails.root.join('tmp')) do |tempfile|
|
|
| 300 |
zip_file = Attachment.attachments_to_zip(tempfile, [attachment_1, attachment_2]) |
|
| 301 |
zip_file_names = ['testfile.txt', 'testfile(2).txt'] |
|
| 302 | ||
| 303 |
Zip::File.open(zip_file.path) do |z| |
|
| 304 |
z.each_with_index do |entry, i| |
|
| 305 |
assert_includes zip_file_names[i], entry.name |
|
| 306 |
end |
|
| 307 |
end |
|
| 308 |
end |
|
| 309 |
end |
|
| 310 | ||
| 281 | 311 |
def test_move_from_root_to_target_directory_should_move_root_files |
| 282 | 312 |
a = Attachment.find(20) |
| 283 | 313 |
assert a.disk_directory.blank? |