Project

General

Profile

Feature #43978 » 0001-Add-ZIP-export-for-all-pages-in-a-project-s-wiki.patch

Go MAEDA, 2026-04-19 10:50

View differences:

app/controllers/wiki_controller.rb
17 17
# along with this program; if not, write to the Free Software
18 18
# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
19 19

  
20
require 'zip'
21

  
20 22
# The WikiController follows the Rails REST controller pattern but with
21 23
# a few differences
22 24
#
......
320 322
      format.pdf do
321 323
        send_file_headers! :type => 'application/pdf', :filename => "#{@project.identifier}.pdf"
322 324
      end
325
      format.zip do
326
        file_name = "#{@project.identifier}-wiki.zip"
327
        send_data(
328
          wiki_pages_to_zip(@pages),
329
          :type => Redmine::MimeType.of(file_name),
330
          :filename => filename_for_content_disposition(file_name)
331
        )
332
      end
323 333
    end
324 334
  end
325 335

  
......
400 410
                includes(:parent).
401 411
                to_a
402 412
  end
413

  
414
  def wiki_pages_to_zip(pages)
415
    Zip.unicode_names = true
416
    archived_file_names = []
417
    buffer = Zip::OutputStream.write_buffer do |zos|
418
      pages.each do |page|
419
        filename = archived_wiki_page_filename(page, archived_file_names)
420
        entry = Zip::Entry.new('', filename)
421
        if page.updated_on.present?
422
          local_time = User.current.convert_time_to_user_timezone(page.updated_on)
423
          # DOS timestamp stores user's displayed local time
424
          entry.time = Zip::DOSTime.new(
425
            local_time.year, local_time.month, local_time.day,
426
            local_time.hour, local_time.min, local_time.sec
427
          )
428
          # UT extra field stores time in UTC
429
          entry.extra[:universaltime].mtime = local_time.utc
430
        end
431
        zos.put_next_entry(entry)
432
        zos << page.content.text.to_s
433
      end
434
    end
435
    buffer.string
436
  ensure
437
    buffer&.close
438
  end
439

  
440
  def archived_wiki_page_filename(page, archived_file_names)
441
    extension = '.txt'
442
    # Wiki titles are already titleized/validated to exclude spaces and the
443
    # characters ',', '.', '/', '?', ';', '|', ':'. We also normalize
444
    # '\', '*', '"', '<', '>' here so ZIP entries stay portable across
445
    # unzip tools and filesystems, especially on Windows.
446
    sanitized_title = page.title.gsub(/[\\*"<>]/, '_')
447
    filename = "#{sanitized_title}#{extension}"
448
    dup_count = 0
449

  
450
    while archived_file_names.include?(filename)
451
      dup_count += 1
452
      filename = "#{sanitized_title}(#{dup_count})#{extension}"
453
    end
454

  
455
    archived_file_names << filename
456
    filename
457
  end
403 458
end
app/views/wiki/date_index.html.erb
32 32
  <% if User.current.allowed_to?(:export_wiki_pages, @project) %>
33 33
  <%= f.link_to('PDF', :url => {:action => 'export', :format => 'pdf'}) %>
34 34
  <%= f.link_to('HTML', :url => {:action => 'export'}) %>
35
  <%= f.link_to('ZIP', :url => {:action => 'export', :format => 'zip'}) %>
35 36
  <% end %>
36 37
  <%= f.link_to 'Atom', :url => {:controller => 'activities', :action => 'index', :id => @project, :show_wiki_edits => 1, :key => User.current.atom_key} %>
37 38
<% end %>
app/views/wiki/index.html.erb
25 25
  <% if User.current.allowed_to?(:export_wiki_pages, @project) %>
26 26
  <%= f.link_to('PDF', :url => {:action => 'export', :format => 'pdf'}) %>
27 27
  <%= f.link_to('HTML', :url => {:action => 'export'}) %>
28
  <%= f.link_to('ZIP', :url => {:action => 'export', :format => 'zip'}) %>
28 29
  <% end %>
29 30
  <%= f.link_to 'Atom',
30 31
                :url => {:controller => 'activities', :action => 'index',
test/functional/wiki_controller_test.rb
1146 1146
    assert @response.body.starts_with?('%PDF')
1147 1147
  end
1148 1148

  
1149
  def test_export_to_zip
1150
    with_settings :text_formatting => 'textile' do
1151
      user = User.find(2)
1152
      user.pref.update!(:time_zone => 'Tokyo')
1153

  
1154
      @request.session[:user_id] = user.id
1155
      get :export, :params => {:project_id => 'ecookbook', :format => 'zip'}
1156

  
1157
      assert_response :success
1158
      assert_equal 'application/zip', @response.media_type
1159
      assert_equal "attachment; filename=\"ecookbook-wiki.zip\"; filename*=UTF-8''ecookbook-wiki.zip",
1160
                   @response.headers['Content-Disposition']
1161

  
1162
      pages = Project.find(1).wiki.pages.includes(:content).to_a.index_by(&:title)
1163
      zip_entries = zip_entries_from_response
1164

  
1165
      assert_equal pages.keys.sort.map {|title| "#{title}.txt"}, zip_entries.keys.sort
1166

  
1167
      zip_entries.each do |name, entry|
1168
        title = name.delete_suffix('.txt')
1169
        page = pages.fetch(title)
1170
        local_time = user.convert_time_to_user_timezone(page.updated_on)
1171

  
1172
        assert_equal page.content.text, entry[:content]
1173
        # DOS timestamp should match the user's displayed local time.
1174
        assert_equal [local_time.year, local_time.month, local_time.day, local_time.hour, local_time.min, local_time.sec],
1175
                     [entry[:time].year, entry[:time].month, entry[:time].day, entry[:time].hour, entry[:time].min, entry[:time].sec]
1176
        # UT extra field should store the corresponding absolute UTC time.
1177
        assert_equal local_time.utc.to_i, entry[:utc_time].utc.to_i
1178
      end
1179
    end
1180
  end
1181

  
1182
  def test_export_to_zip_should_sanitize_non_portable_entry_name_characters
1183
    with_settings :text_formatting => 'textile' do
1184
      page = Project.find(1).wiki.pages.new(:title => 'Foo*')
1185
      page.content = WikiContent.new(:text => 'sanitized')
1186
      assert_save page
1187

  
1188
      @request.session[:user_id] = 2
1189
      get :export, :params => {:project_id => 'ecookbook', :format => 'zip'}
1190

  
1191
      assert_response :success
1192

  
1193
      zip_entries = zip_entries_from_response
1194
      assert_equal 'sanitized', zip_entries['Foo_.txt'][:content]
1195
      assert_not_includes zip_entries.keys, 'Foo*.txt'
1196
    end
1197
  end
1198

  
1149 1199
  def test_export_without_permission_should_be_denied
1150 1200
    @request.session[:user_id] = 2
1151 1201
    Role.find_by_name('Manager').remove_permission! :export_wiki_pages
......
1333 1383
    assert_response :success
1334 1384
    assert_select 'head>meta[name="robots"]', false
1335 1385
  end
1386

  
1387
  private
1388

  
1389
  def zip_entries_from_response
1390
    entries = {}
1391

  
1392
    Zip::InputStream.open(StringIO.new(@response.body)) do |io|
1393
      while (entry = io.get_next_entry)
1394
        entries[entry.name] = {
1395
          :content => io.read,
1396
          :time => entry.time,
1397
          :utc_time => entry.extra[:universaltime]&.mtime
1398
        }
1399
      end
1400
    end
1401

  
1402
    entries.transform_keys {|name| name.dup.force_encoding('UTF-8')}
1403
  end
1336 1404
end
test/integration/routing/wiki_test.rb
26 26
    should_route 'GET /projects/foo/wiki/date_index' => 'wiki#date_index', :project_id => 'foo'
27 27
    should_route 'GET /projects/foo/wiki/export' => 'wiki#export', :project_id => 'foo'
28 28
    should_route 'GET /projects/foo/wiki/export.pdf' => 'wiki#export', :project_id => 'foo', :format => 'pdf'
29
    should_route 'GET /projects/foo/wiki/export.zip' => 'wiki#export', :project_id => 'foo', :format => 'zip'
29 30
  end
30 31

  
31 32
  def test_wiki_pages
(2-2/2)