Project

General

Profile

Feature #7056 » compress_the_all_attachments_in_issue_v4.patch

Go MAEDA, 2020-02-18 08:42

View differences:

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?
(6-6/22)