ee9f71f7bf46b0a5ba183ea537839015cb427d6e
[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         if !@warned_of_rewind && env['rack.input'].respond_to?(:rewind)
36           env['rack.logger'].warn <<-MESSAGE.gsub(/\n */m, ' ').strip
37             It looks like the input stream is "rewindable". This means that
38             somewhere along the process, the input request is probably buffered,
39             either into memory or in a temporary file. In both case Coquelicot
40             will not scale to big files, and in the later one, it might be a
41             breach of privacy: the temporary file might be written to disk.
42             Please use Rainbows! to serve web request for Coquelicot, which
43             has been tested to provide and work with a fully streamed input.
44           MESSAGE
45           @warned_of_rewind = true
46         end
47         dup.call!(env)
48       else
49         unless env['rack.input'].respond_to? :rewind
50           env['rack.input'] = Rack::RewindableInput.new(env['rack.input'])
51         end
52         @app.call(env)
53       end
54     end
55
56   protected
57
58     def handle_request?(env)
59       env['REQUEST_METHOD'] == 'POST' && env['PATH_INFO'] == '/upload'
60     end
61
62     # This acts much like Sinatra's, but without request parsing,
63     # as we have our own method here.
64     def call!(env)
65       @env = env
66       @request = Request.new(env)
67       @response = Sinatra::Response.new
68
69       @response['Content-Type'] = nil
70       invoke { dispatch! }
71       invoke { error_block!(response.status) }
72
73       unless @response['Content-Type']
74         if Array === body and body[0].respond_to? :content_type
75           content_type body[0].content_type
76         else
77           content_type :html
78         end
79       end
80
81       @response.finish
82     end
83
84     def dispatch!
85       catch(:pass) do
86         return process!
87       end
88       forward
89     end
90
91     def process!
92       # Stop users right now if input has already said the file is too big.
93       length = @env['CONTENT_LENGTH']
94       unless length.nil?
95         length = length.to_i
96         error_for_max_length(length) if length > Coquelicot.settings.max_file_size
97       end
98
99       MultipartParser.parse(@env) do |p|
100         p.start do
101           @expire = Coquelicot.settings.default_expire
102           @file_key = ''
103           @pass = Coquelicot.gen_random_pass
104         end
105         p.many_fields do |params|
106           @auth_params = params
107           begin
108             @authenticated = Coquelicot.settings.authenticator.authenticate(@auth_params)
109           rescue Coquelicot::Auth::Error => ex
110             error 503, ex.message
111           end
112         end
113         p.field :expire do |value|
114           if value.to_i > Coquelicot.settings.maximum_expire
115             error 403, 'Forbidden: expiration time too big'
116           end
117           @expire = value
118         end
119         p.field :one_time do |value|
120           @one_time_only = value && value == 'true'
121         end
122         p.field :file_key do |value|
123           @pass = @file_key = value unless value.empty?
124         end
125         p.file :file do |filename, type, reader|
126           error 403, 'Forbidden' unless @authenticated
127
128           max_length = Coquelicot.settings.max_file_size
129           # We still compute the length of the received data manually, in case
130           # input was lying.
131           length = 0
132           @link = Coquelicot.depot.add_file(
133                     @pass,
134                     'Expire-at' => Time.now + 60 * @expire.to_i,
135                     'One-time-only' => @one_time_only,
136                     'Filename' => filename,
137                     'Content-Type' => type) do
138             data = reader.call
139             unless data.nil?
140               length += data.bytesize
141               error_for_max_length if length > max_length
142             else
143               error_for_empty if length == 0
144             end
145             data
146           end
147         end
148         p.field :submit
149         p.finish do
150           unless @link.nil?
151             redirect to(@file_key.empty? ? "/ready/#{@link}-#{@pass}" : "/ready/#{@link}")
152           else
153             params = @auth_params || {}
154             params['expire'] = @expire
155             params['one_time'] = 'true' if @one_time_only
156
157             rewrite_input! params
158             pass # will forward to the next Rack middlware
159           end
160         end
161       end
162     rescue EOFError => e
163       raise unless e.message.start_with?('Unexpected part')
164       error 400, 'Bad Request: fields in unacceptable order'
165     end
166
167     def forward
168       # The following is to authenticate the request arriving
169       # in Coquelicot::Application
170       @env['X_COQUELICOT_FORWARD'] = 'Yes'
171       super
172     end
173
174     def error_for_max_length(length = nil)
175       # XXX: i18nize
176       if length
177         message = <<-MESSAGE.gsub(/\n */m, ' ').strip
178           File is bigger than maximum allowed size:
179           #{length.as_size} would exceed the
180           maximum allowed #{Coquelicot.settings.max_file_size.as_size}.
181         MESSAGE
182       else
183         message = <<-MESSAGE.gsub(/\n */m, ' ').strip
184           File is bigger than maximum allowed size
185           (#{Coquelicot.settings.max_file_size.as_size}).
186         MESSAGE
187       end
188       error 413, message
189     end
190
191     def error_for_empty
192       # XXX: i18nize
193       error 403, 'File has no content'
194     end
195
196     # This will create a new (rewindable) input with the given params
197     def rewrite_input!(params)
198       @env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
199       data = Rack::Utils.build_nested_query(params)
200       env['rack.input'] = StringIO.new(data)
201     end
202   end
203 end