aboutsummaryrefslogtreecommitdiffstats
path: root/lib/active_storage/download.rb
blob: 4d656942d8b8bf971e923f144ffb8c8f5c4349aa (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
class ActiveStorage::Download
  # Sending .ai files as application/postscript to Safari opens them in a blank, grey screen.
  # Downloading .ai as application/postscript files in Safari appends .ps to the extension.
  # Sending HTML, SVG, XML and SWF files as binary closes XSS vulnerabilities.
  # Sending JS files as binary avoids InvalidCrossOriginRequest without compromising security.
  CONTENT_TYPES_TO_RENDER_AS_BINARY = %w(
    text/html
    text/javascript
    image/svg+xml
    application/postscript
    application/x-shockwave-flash
    text/xml
    application/xml
    application/xhtml+xml
  )

  BINARY_CONTENT_TYPE = 'application/octet-stream'

  def initialize(stored_file)
    @stored_file = stored_file
  end

  def headers(force_attachment: false)
    {
      x_accel_redirect:    '/reproxy',
      x_reproxy_url:       reproxy_url,
      content_type:        content_type,
      content_disposition: content_disposition(force_attachment),
      x_frame_options:     'SAMEORIGIN'
    }
  end

  private
    def reproxy_url
      @stored_file.depot_location.paths.first
    end

    def content_type
      if @stored_file.content_type.in? CONTENT_TYPES_TO_RENDER_AS_BINARY
        BINARY_CONTENT_TYPE
      else
        @stored_file.content_type
      end
    end

    def content_disposition(force_attachment = false)
      if force_attachment || content_type == BINARY_CONTENT_TYPE
        "attachment; #{escaped_filename}"
      else
        "inline; #{escaped_filename}"
      end
    end

    # RFC2231 encoding for UTF-8 filenames, with an ASCII fallback
    # first for unsupported browsers (IE < 9, perhaps others?).
    # http://greenbytes.de/tech/tc2231/#encoding-2231-fb
    def escaped_filename
      filename = @stored_file.filename.sanitized
      ascii_filename = encode_ascii_filename(filename)
      utf8_filename = encode_utf8_filename(filename)
      "#{ascii_filename}; #{utf8_filename}"
    end

    TRADITIONAL_PARAMETER_ESCAPED_CHAR = /[^ A-Za-z0-9!#$+.^_`|~-]/

    def encode_ascii_filename(filename)
      # There is no reliable way to escape special or non-Latin characters
      # in a traditionally quoted Content-Disposition filename parameter.
      # Settle for transliterating to ASCII, then percent-escaping special
      # characters, excluding spaces.
      filename = I18n.transliterate(filename)
      filename = percent_escape(filename, TRADITIONAL_PARAMETER_ESCAPED_CHAR)
      %(filename="#{filename}")
    end

    RFC5987_PARAMETER_ESCAPED_CHAR = /[^A-Za-z0-9!#$&+.^_`|~-]/

    def encode_utf8_filename(filename)
      # RFC2231 filename parameters can simply be percent-escaped according
      # to RFC5987.
      filename = percent_escape(filename, RFC5987_PARAMETER_ESCAPED_CHAR)
      %(filename*=UTF-8''#{filename})
    end

    def percent_escape(string, pattern)
      string.gsub(pattern) do |char|
        char.bytes.map { |byte| "%%%02X" % byte }.join("")
      end
    end
end