unwrap Upr::InputWrapper to detect a rewindable input
[coquelicot.git] / lib / coquelicot / rack / upload.rb
1 # Coquelicot: "one-click" file sharing with a focus on users' privacy.
2 # Copyright © 2012 potager.org <jardiniers@potager.org>
3 #
4 # This program is free software: you can redistribute it and/or modify
5 # it under the terms of the GNU Affero General Public License as
6 # published by the Free Software Foundation, either version 3 of the
7 # License, or (at your option) any later version.
8 #
9 # This program is distributed in the hope that it will be useful,
10 # but WITHOUT ANY WARRANTY; without even the implied warranty of
11 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 # GNU Affero General Public License for more details.
13 #
14 # You should have received a copy of the GNU Affero General Public License
15 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
16
17 require 'sinatra/base'
18 require 'rack/utils'
19 require 'rack/rewindable_input'
20 require 'tempfile'
21
22 module Coquelicot::Rack
23   # a Request class that leaves applications to deal with POST data
24   class Request < Sinatra::Request
25     def POST
26       {}
27     end
28   end
29
30   class Upload < Sinatra::Base
31     set :logging, true
32
33     def call(env)
34       if handle_request?(env)
35         input = env['rack.input']
36         input = input.input if input.is_a? Upr::InputWrapper
37         if !@warned_of_rewind && input.respond_to?(:rewind)
38           env['rack.logger'].warn <<-MESSAGE.gsub(/\n */m, ' ').strip
39             It looks like the input stream is "rewindable". This means that
40             somewhere along the process, the input request is probably buffered,
41             either into memory or in a temporary file. In both case Coquelicot
42             will not scale to big files, and in the later one, it might be a
43             breach of privacy: the temporary file might be written to disk.
44             Please use Rainbows! to serve web request for Coquelicot, which
45             has been tested to provide and work with a fully streamed input.
46           MESSAGE
47           @warned_of_rewind = true
48         end
49         dup.call!(env)
50       else
51         unless env['rack.input'].respond_to? :rewind
52           env['rack.input'] = Rack::RewindableInput.new(env['rack.input'])
53         end
54         @app.call(env)
55       end
56     end
57
58   protected
59
60     def handle_request?(env)
61       env['REQUEST_METHOD'] == 'POST' && env['PATH_INFO'] == '/upload'
62     end
63
64     # This acts much like Sinatra's, but without request parsing,
65     # as we have our own method here.
66     def call!(env)
67       @env = env
68       @request = Request.new(env)
69       @response = Sinatra::Response.new
70
71       @response['Content-Type'] = nil
72       invoke { dispatch! }
73       invoke { error_block!(response.status) }
74
75       unless @response['Content-Type']
76         if Array === body and body[0].respond_to? :content_type
77           content_type body[0].content_type
78         else
79           content_type :html
80         end
81       end
82
83       @response.finish
84     end
85
86     def dispatch!
87       catch(:pass) do
88         return process!
89       end
90       forward
91     end
92
93     def process!
94       # Stop users right now if input has already said the file is too big.
95       length = @env['CONTENT_LENGTH']
96       unless length.nil?
97         length = length.to_i
98         error_for_max_length(length) if length > Coquelicot.settings.max_file_size
99       end
100
101       MultipartParser.parse(@env) do |p|
102         p.start do
103           @expire = Coquelicot.settings.default_expire
104           @file_key = ''
105           @pass = Coquelicot.gen_random_pass
106         end
107         p.many_fields do |params|
108           @auth_params = params
109           begin
110             @authenticated = Coquelicot.settings.authenticator.authenticate(@auth_params)
111           rescue Coquelicot::Auth::Error => ex
112             error 503, ex.message
113           end
114         end
115         p.field :expire do |value|
116           if value.to_i > Coquelicot.settings.maximum_expire
117             error 403, 'Forbidden: expiration time too big'
118           end
119           @expire = value
120         end
121         p.field :one_time do |value|
122           @one_time_only = value && value == 'true'
123         end
124         p.field :file_key do |value|
125           @pass = @file_key = value unless value.empty?
126         end
127         p.file :file do |filename, type, reader|
128           error 403, 'Forbidden' unless @authenticated
129
130           max_length = Coquelicot.settings.max_file_size
131           # We still compute the length of the received data manually, in case
132           # input was lying.
133           length = 0
134           @link = Coquelicot.depot.add_file(
135                     @pass,
136                     'Expire-at' => Time.now + 60 * @expire.to_i,
137                     'One-time-only' => @one_time_only,
138                     'Filename' => filename,
139                     'Content-Type' => type) do
140             data = reader.call
141             unless data.nil?
142               length += data.bytesize
143               error_for_max_length if length > max_length
144             else
145               error_for_empty if length == 0
146             end
147             data
148           end
149         end
150         p.field :submit
151         p.finish do
152           unless @link.nil?
153             redirect to(@file_key.empty? ? "/ready/#{@link}-#{@pass}" : "/ready/#{@link}")
154           else
155             params = @auth_params || {}
156             params['expire'] = @expire
157             params['one_time'] = 'true' if @one_time_only
158
159             rewrite_input! params
160             pass # will forward to the next Rack middlware
161           end
162         end
163       end
164     rescue EOFError => e
165       raise unless e.message.start_with?('Unexpected part')
166       error 400, 'Bad Request: fields in unacceptable order'
167     end
168
169     def forward
170       # The following is to authenticate the request arriving
171       # in Coquelicot::Application
172       @env['X_COQUELICOT_FORWARD'] = 'Yes'
173       super
174     end
175
176     def error_for_max_length(length = nil)
177       # XXX: i18nize
178       if length
179         message = <<-MESSAGE.gsub(/\n */m, ' ').strip
180           File is bigger than maximum allowed size:
181           #{length.as_size} would exceed the
182           maximum allowed #{Coquelicot.settings.max_file_size.as_size}.
183         MESSAGE
184       else
185         message = <<-MESSAGE.gsub(/\n */m, ' ').strip
186           File is bigger than maximum allowed size
187           (#{Coquelicot.settings.max_file_size.as_size}).
188         MESSAGE
189       end
190       error 413, message
191     end
192
193     def error_for_empty
194       # XXX: i18nize
195       error 403, 'File has no content'
196     end
197
198     # This will create a new (rewindable) input with the given params
199     def rewrite_input!(params)
200       @env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
201       data = Rack::Utils.build_nested_query(params)
202       env['rack.input'] = StringIO.new(data)
203     end
204   end
205 end