properly handle large file uploads
authorLunar <lunar@anargeek.net>
Sun, 26 Feb 2012 19:44:43 +0000 (20:44 +0100)
committerLunar <lunar@anargeek.net>
Thu, 14 Mar 2013 09:12:08 +0000 (10:12 +0100)
Previously we were using Sinatra::Request to process file uploads. This class
derives from Rack::Request which creates a temporary file for each file
appearing in a POST request. This can be seen as a privacy breach, as it means
uploaded files were first written in clear text before being stored encrypted.
This can be mitigated by storing the tempfile on a "ramdisk", but then, memory
can pretty quick be a limit to the maximum uploaded file size.

But wait, there's more: Rack specify that `rack.input` must be a
seakable/rewindable IO-like object. In order to implement that, Rack webserver
will either buffer the input in memory (Webrick) or in a temporary file
(Thin, Passenger or Mongrel). So in most cases we had not one, but at least two
temporary files for each uploads.

In order to properly process uploaded file content as it arrives, we 1. switch
to use the "Rainbows!" webserver and 2. handle the POST request directly.

Rainbows! has a unique feature of being able to provide a non-buffered input.
While this breaks Rack specification, our own dedicated handler is written
specifically with this in mind.

Handling the POST request as its input flows requires to be careful with the
order in which fields appear in the `<form/>` tag (HTML specification specify
that they will be sent in that particular order). As we want to know all
options before writing the StoredFile, we need to have the `<input
type="file"/>` field at the end of our form. Along the same lines, we ensure
in `coquelicot.js` that hidden fields for authentication values are laid at the
verify begining of the upload `<form/>`.

Coquelicot::Rack::MultipartParser offers a generic interface to parse
`multipart/form-data` requests. It offers a simple DSL to specify which field
is expected, and to run specific block when they shows up.

Coquelicot::Rack::Upload replaces our old `post '/upload'` method. It handles
the request as a bare Rack middleware to be laid on top of the stack. Its code
borrows part of Sinatra's internals in order to get consistent coding
interface.

Huge kudos to Eric Wong for Rainbows! and Daniel Abrahamsson for
multipart-parser which both made this possible.

12 files changed:
Gemfile
Gemfile.lock
README
lib/coquelicot.rb
lib/coquelicot/app.rb
lib/coquelicot/rack/multipart_parser.rb [new file with mode: 0644]
lib/coquelicot/rack/upload.rb [new file with mode: 0644]
public/javascripts/coquelicot.js
spec/coquelicot/rack/multipart_parser_spec.rb [new file with mode: 0644]
spec/coquelicot/rack/upload_spec.rb [new file with mode: 0644]
spec/coquelicot_spec.rb
views/index.haml

diff --git a/Gemfile b/Gemfile
index 4c95c06..f20d0de 100644 (file)
--- a/Gemfile
+++ b/Gemfile
@@ -11,6 +11,8 @@ gem "maruku"
 gem "fast_gettext"
 gem "lockfile", "~>1.4.3"
 gem "json"
+gem "rainbows"
+gem "multipart-parser"
 
 group :test do
   gem "rspec", "~>2.0"
@@ -18,6 +20,7 @@ group :test do
   gem "timecop", "~>0.3.5"
   gem "rack-test", "~>0.5.7"
   gem "capybara"
+  gem "active_support"
 end
 
 group :development do
index c6bd658..2e36e59 100644 (file)
@@ -9,6 +9,9 @@ GIT
 GEM
   remote: http://rubygems.org/
   specs:
+    active_support (3.0.0)
+      activesupport (= 3.0.0)
+    activesupport (3.0.0)
     backports (2.3.0)
     capybara (1.1.2)
       mime-types (>= 1.16)
@@ -29,18 +32,25 @@ GEM
     haml (3.1.4)
     hpricot (0.8.6)
     json (1.6.5)
+    kgio (2.7.2)
     locale (2.0.5)
     lockfile (1.4.3)
     maruku (0.6.0)
       syntax (>= 1.0.0)
     mime-types (1.17.2)
     multi_json (1.0.4)
+    multipart-parser (0.1.0)
     nokogiri (1.5.0)
     rack (1.4.1)
     rack-protection (1.2.0)
       rack
     rack-test (0.5.7)
       rack (>= 1.0)
+    rainbows (4.3.1)
+      kgio (~> 2.5)
+      rack (~> 1.1)
+      unicorn (~> 4.1)
+    raindrops (0.8.0)
     rspec (2.8.0)
       rspec-core (~> 2.8.0)
       rspec-expectations (~> 2.8.0)
@@ -74,6 +84,10 @@ GEM
       rack (>= 1.0.0)
     tilt (1.3.3)
     timecop (0.3.5)
+    unicorn (4.2.0)
+      kgio (~> 2.6)
+      rack
+      raindrops (~> 0.7)
     xpath (0.1.4)
       nokogiri (~> 1.3)
 
@@ -81,6 +95,7 @@ PLATFORMS
   ruby
 
 DEPENDENCIES
+  active_support
   capybara
   fast_gettext
   gettext (~> 2.1.0)
@@ -90,8 +105,10 @@ DEPENDENCIES
   json
   lockfile (~> 1.4.3)
   maruku
+  multipart-parser
   rack (~> 1.1)
   rack-test (~> 0.5.7)
+  rainbows
   rspec (~> 2.0)
   sass
   sinatra (~> 1.3)
diff --git a/README b/README
index 1ef9024..4064398 100644 (file)
--- a/README
+++ b/README
@@ -117,18 +117,18 @@ Once Bundler is available, please issue:
 
 Then, to start Coquelicot use:
 
-    $ bin/rackup config.ru
+    $ bin/rainbows -c rainbows.conf -E none config.ru
 
 Coquelicot is intended to be run on a fully encrypted system and
 accessible only through HTTPS. To configure Apache as a reverse proxy,
 you will need to add the following directives:
 
-    ProxyPass / http://127.0.0.1:9292/
+    ProxyPass / http://127.0.0.1:51161/
     SetEnv proxy-sendchunks 1
     RequestHeader set X-Forwarded-SSL "on"
 
 You can also run Coquelicot with mod_passenger, Mongrel, Thin or any
-Rack compatible webserver.
+Rack compatible webserver, but please read below about buffered input.
 
 ### Configuration
 
@@ -241,6 +241,54 @@ with the following responsabilities:
    The authentication method can be set in the application settings
    including mandatory options for this method.
 
+### Watch for buffered inputs!
+
+Coquelicot is written in Ruby using Sinatra. Sinatra is based on the
+Rack webserver interface. Rack specification mandates that applications
+must be able to seek and rewind freely in the request content.
+
+Request data are always received as a stream through the network. So in
+order to comply with the specification, webservers implementing Rack will
+either buffer the input in memory (Webrick) or in a temporary file
+(Thin, Passenger or Mongrel).
+
+On top of that, when parsing `multipart/form-data` POST content,
+`Rack::Request` (used by Sinatra) will create a new temporary file for
+each files in the POST request.
+
+For the specific needs of Coquelicot, those behaviours will prevent
+users from uploading large files (if `/tmp` is in memory) or will be a
+breach of privacy, as a clear text version will be written to disk.
+
+To overcome these limitations, Coquelicot first uses a specific feature
+of the Rainbows! webserver of streaming its input directly to
+applications, and second bypass `Rack::Request` to directly handle
+POST content. Usage of any other Rack webserver is strongly discouraged
+and should be restricted to development and testing.
+
+### Implementation details
+
+Common application code lies in `Coquelicot::Application`. Except for
+one specific (and important) type of requests, namely `POST /update`.
+These requests are handled directly at bare Rack level by
+`Coquelicot::Rack::Upload`.
+
+This allows to work directly with POST data as the browser is sending
+them, so we can directly stream the uploaded file to our encrypted
+on-disk containers.
+
+The POST data must be in a very specific order, as we need to handle
+authentication, and various options prior to start recording the file
+content. Thanks to the W3C, the [HTML specification] states that parts
+of the POST data must be delivered in the same order the controls
+appears in the `<form/>` container.
+
+`Coquelicot::Rack::Multipart` expose a simple DSL to parse the fields as
+they are delivered. The later is used by `Coquelicot::Rack::Upload` to
+perform its logic pretty nicely.
+
+[HTML specification]: http://www.w3.org/TR/html4/interact/forms.html
+
 Future
 ------
 
@@ -263,11 +311,6 @@ Future
 
    A Debian package would be nice to spread Coquelicot setups.
 
- * Describe more setups
-
-   Describe how to setup Coquelicot with mod_passenger, Mongrel and
-   other webservers.
-
 Storage details
 ---------------
 
index b377ef6..60c7461 100644 (file)
@@ -18,4 +18,6 @@
 require 'coquelicot/auth'
 require 'coquelicot/stored_file'
 require 'coquelicot/depot'
+require 'coquelicot/rack/multipart_parser'
+require 'coquelicot/rack/upload'
 require 'coquelicot/app'
index 4bdf421..f338c4b 100644 (file)
@@ -36,6 +36,8 @@ module Coquelicot
   end
 
   class Application < Sinatra::Base
+    use Coquelicot::Rack::Upload
+
     register Sinatra::ConfigFile
     register Coquelicot::Auth::Extension
 
@@ -114,49 +116,16 @@ module Coquelicot
     end
 
     post '/upload' do
-      begin
-        unless authenticate(params)
-          error 403, "Forbidden"
-        end
-      rescue Coquelicot::Auth::Error => ex
-        error 503, ex.message
-      end
+      # Normally handled by Coquelicot::Rack::Upload, only failures
+      # will arrive here.
+      error 500, 'Rack::Coquelicot::Upload failed' if @env['X_COQUELICOT_FORWARD'].nil?
 
-      if params[:file] then
-        tmpfile = params[:file][:tempfile]
-        name = params[:file][:filename]
-      end
-      if tmpfile.nil? || name.nil? then
+      if params[:file].nil? then
         @error = "No file selected"
         return haml(:index)
       end
-      if tmpfile.lstat.size == 0 then
-        @error = "#{name} is empty"
-        return haml(:index)
-      end
-      if params[:expire].nil? or params[:expire].to_i == 0 then
-        params[:expire] = settings.default_expire
-      elsif params[:expire].to_i > settings.maximum_expire then
-        error 403
-      end
-      expire_at = Time.now + 60 * params[:expire].to_i
-      one_time_only = params[:one_time] and params[:one_time] == 'true'
-      if params[:file_key].nil? or params[:file_key].empty?then
-        pass = Coquelicot.gen_random_pass
-      else
-        pass = params[:file_key]
-      end
-      src = params[:file][:tempfile]
-      src.rewind
-      link = Coquelicot.depot.add_file(
-         pass,
-         { "Expire-at" => expire_at.to_i,
-           "One-time-only" => one_time_only,
-           "Filename" => params[:file][:filename],
-           "Content-Type" => params[:file][:type],
-         }) { src.eof? ? nil : src.read }
-      redirect to("/ready/#{link}-#{pass}") if params[:file_key].nil? or params[:file_key].empty?
-      redirect to("/ready/#{link}")
+
+      error 500, 'Something went wrong: this code should never be executed'
     end
 
     def expired
diff --git a/lib/coquelicot/rack/multipart_parser.rb b/lib/coquelicot/rack/multipart_parser.rb
new file mode 100644 (file)
index 0000000..4799e7f
--- /dev/null
@@ -0,0 +1,248 @@
+# Coquelicot: "one-click" file sharing with a focus on users' privacy.
+# Copyright © 2012 potager.org <jardiniers@potager.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+require 'rack/multipart'
+require 'rack/utils'
+require 'multipart_parser/reader'
+
+module Coquelicot::Rack
+  class ManyFieldsStep < Struct.new(:block)
+    def initialize(block)
+      super
+      @params = Rack::Utils::KeySpaceConstrainedParams.new
+    end
+
+    def call_handler
+      block.call(indifferent_params(@params.to_params_hash)) unless block.nil?
+    end
+
+    def add_param(name, data)
+      Rack::Utils.normalize_params(@params, name, data)
+    end
+
+    # borrowed from Sinatra::Base
+    def indifferent_params(params)
+      params = indifferent_hash.merge(params)
+      params.each do |key, value|
+        next unless value.is_a?(Hash)
+        params[key] = indifferent_params(value)
+      end
+    end
+
+    # borrowed from Sinatra::Base
+    def indifferent_hash
+      Hash.new {|hash,key| hash[key.to_s] if Symbol === key }
+    end
+  end
+  class FieldStep < Struct.new(:name, :block) ; end
+  class FileStep < Struct.new(:name, :block) ; end
+
+  class MultipartParser
+    BUFFER_SIZE = 4096
+
+    class << self
+      alias :create :new
+
+      def parse(env, &block)
+        parser = MultipartParser.create(env)
+        yield parser
+        parser.send(:run)
+      end
+
+      alias :new :parse
+    end
+
+    # Run the given block before first field
+    def start(&block)
+      raise ArgumentError.new('#start requires a block') if block.nil?
+      @start << block
+    end
+
+    # Parse any number of fields and execute the given block once the next step
+    # has been reached
+    def many_fields(&block)
+      @steps << ManyFieldsStep.new(block)
+    end
+
+    # Parse the given field and execute the given block. If no block is given
+    # the content of the field will be lost.
+    def field(name, &block)
+      @steps << FieldStep.new(name, block)
+    end
+
+    # Parse a file field with the given name
+    #
+    # The block will receive the filename, content type and a 'reader' proc.
+    # The later MUST use the '#call' method to retreive the next part of file
+    # content. It will return 'nil' once end of file has been reached.
+    def file(name, &block)
+      @steps << FileStep.new(name, block)
+    end
+
+    # Run the given block when reaching the end of the multipart data
+    #
+    # Subsequent calls will be run in the reverse order
+    def finish(&block)
+      raise ArgumentError.new('#finish requires a block') if block.nil?
+      @finish.unshift block
+    end
+
+  private
+
+    def initialize(env)
+      @env = env
+      @start = []
+      @finish = []
+      @steps = []
+    end
+
+    def run
+      @io = @env['rack.input']
+
+      @reader = ::MultipartParser::Reader.new(boundary)
+      @reader.on_error do |msg|
+        @events << [:error, msg]
+      end
+      @reader.on_part do |part|
+        @events << [:part, part]
+        part.on_data { |data| @events << [:part_data, data] }
+        part.on_end  { @events << [:part_end] }
+      end
+
+      @start.each { |block| block.call }
+
+      @events = []
+      parse_input do |event, *args|
+        case event
+          when :error
+            msg = args.shift
+            raise EOFError.new("Unable to parse request body: #{msg}")
+          when :part
+            part = args.shift
+            handle_part part
+          when :part_data, :part_end
+            raise StandardError.new("Out of order: #{event}")
+        end
+      end
+
+      @current_step.call_handler if @current_step.is_a? ManyFieldsStep
+      @finish.each { |block| block.call }
+    end
+
+    def parse_input(&block)
+      loop do
+        block.call(*@events.shift) until @events.empty?
+
+        buf = @io.read(BUFFER_SIZE)
+        break if buf.nil?
+        @reader.write buf
+        break if @reader.ended? && @events.empty?
+      end
+    end
+
+    def boundary
+      ::MultipartParser::Reader.extract_boundary_value(@env['CONTENT_TYPE'])
+    end
+
+    def handle_part(part)
+      previous, @current_step = @current_step, lookup_steps!(part.name)
+      if @current_step.nil?
+        if previous.is_a? ManyFieldsStep
+          # we can still parse more fields
+          @current_step, previous = previous, nil
+        else
+          # a new part and no more steps, something is wrong!
+          raise EOFError.new("Unexpected part #{part.name}")
+        end
+      end
+
+      if previous.is_a? ManyFieldsStep
+          # call handler if we are moving to more specific steps
+          previous.call_handler unless @current_step.is_a? ManyFieldsStep
+      end
+
+      case @current_step
+        when ManyFieldsStep
+          buf = ''
+          parse_input do |event, *args|
+           case event
+             when :part_data
+               data = args.shift
+               buf << data
+             when :part_end
+               @current_step.add_param(part.name, buf)
+               return
+             when :error, :part
+               raise StandardError.new("Out of order: #{event}")
+           end
+        end
+        when FieldStep
+          buf = ''
+          parse_input do |event, *args|
+           case event
+             when :part_data
+               data = args.shift
+               buf << data
+             when :part_end
+               @current_step.block.call(buf) unless @current_step.block.nil?
+               return
+             when :error, :part
+               raise StandardError.new("Out of order: #{event}")
+           end
+        end
+        when FileStep
+          @current_step.block.call(part.filename, part.mime, lambda {
+            value = nil
+            parse_input do |event, *args|
+              case event
+                when :part_data
+                   value = args.shift
+                   break
+                when :part_end
+                   value = nil
+                   break
+                when :error, :part
+                  raise StandardError.new("Out of order: #{event}")
+              end
+            end
+            value
+          })
+      end
+    end
+
+    def lookup_steps!(name)
+      index = 0
+      found = nil
+      while current = @steps[index]
+        case current
+          when FieldStep, FileStep
+            if current.name.to_s == name
+              found = index
+              break
+            end
+          when ManyFieldsStep
+            unless found && @steps[found].is_a?(ManyFieldsStep)
+              found = index
+            end
+        end
+        index += 1
+      end
+      return nil if found.nil?
+      @steps.slice!(0, found)
+      @steps[0]
+    end
+  end
+end
diff --git a/lib/coquelicot/rack/upload.rb b/lib/coquelicot/rack/upload.rb
new file mode 100644 (file)
index 0000000..18c4591
--- /dev/null
@@ -0,0 +1,175 @@
+# Coquelicot: "one-click" file sharing with a focus on users' privacy.
+# Copyright © 2012 potager.org <jardiniers@potager.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+require 'sinatra/base'
+require 'rack/utils'
+require 'rack/rewindable_input'
+require 'tempfile'
+
+module Coquelicot::Rack
+  # a Request class that leaves applications to deal with POST data
+  class Request < Sinatra::Request
+    def POST
+      {}
+    end
+  end
+
+  class Upload < Sinatra::Base
+    set :logging, true
+
+    def call(env)
+      if handle_request?(env)
+        if !@warned_of_rewind && env['rack.input'].respond_to?(:rewind)
+          env['rack.logger'].warn <<-MESSAGE.gsub(/\n */m, ' ').strip
+            It looks like the input stream is "rewindable". This means that
+            somewhere along the process, the input request is probably buffered,
+            either into memory or in a temporary file. In both case Coquelicot
+            will not scale to big files, and in the later one, it might be a
+            breach of privacy: the temporary file might be written to disk.
+            Please use Rainbows! to serve web request for Coquelicot, which
+            has been tested to provide and work with a fully streamed input.
+          MESSAGE
+          @warned_of_rewind = true
+        end
+        dup.call!(env)
+      else
+        unless env['rack.input'].respond_to? :rewind
+          env['rack.input'] = Rack::RewindableInput.new(env['rack.input'])
+        end
+        @app.call(env)
+      end
+    end
+
+  protected
+
+    def handle_request?(env)
+      env['REQUEST_METHOD'] == 'POST' && env['PATH_INFO'] == '/upload'
+    end
+
+    # This acts much like Sinatra's, but without request parsing,
+    # as we have our own method here.
+    def call!(env)
+      @env = env
+      @request = Request.new(env)
+      @response = Sinatra::Response.new
+
+      @response['Content-Type'] = nil
+      invoke { dispatch! }
+      invoke { error_block!(response.status) }
+
+      unless @response['Content-Type']
+        if Array === body and body[0].respond_to? :content_type
+          content_type body[0].content_type
+        else
+          content_type :html
+        end
+      end
+
+      @response.finish
+    end
+
+    def dispatch!
+      catch(:pass) do
+        return process!
+      end
+      forward
+    end
+
+    def process!
+      MultipartParser.parse(@env) do |p|
+        p.start do
+          @expire = Coquelicot.settings.default_expire
+          @file_key = ''
+          @pass = Coquelicot.gen_random_pass
+        end
+        p.many_fields do |params|
+          @auth_params = params
+          begin
+            @authenticated = Coquelicot.settings.authenticator.authenticate(@auth_params)
+          rescue Coquelicot::Auth::Error => ex
+            error 503, ex.message
+          end
+        end
+        p.field :expire do |value|
+          if value.to_i > Coquelicot.settings.maximum_expire
+            error 403, 'Forbidden: expiration time too big'
+          end
+          @expire = value
+        end
+        p.field :one_time do |value|
+          @one_time_only = value && value == 'true'
+        end
+        p.field :file_key do |value|
+          @pass = @file_key = value unless value.empty?
+        end
+        p.file :file do |filename, type, reader|
+          error 403, 'Forbidden' unless @authenticated
+
+          length = 0
+          @link = Coquelicot.depot.add_file(
+                    @pass,
+                    'Expire-at' => Time.now + 60 * @expire.to_i,
+                    'One-time-only' => @one_time_only,
+                    'Filename' => filename,
+                    'Content-Type' => type) do
+            data = reader.call
+            unless data.nil?
+              length += data.bytesize
+            else
+              error_for_empty if length == 0
+            end
+            data
+          end
+        end
+        p.field :submit
+        p.finish do
+          unless @link.nil?
+            redirect to(@file_key.empty? ? "/ready/#{@link}-#{@pass}" : "/ready/#{@link}")
+          else
+            params = @auth_params || {}
+            params['expire'] = @expire
+            params['one_time'] = 'true' if @one_time_only
+
+            rewrite_input! params
+            pass # will forward to the next Rack middlware
+          end
+        end
+      end
+    rescue EOFError => e
+      raise unless e.message.start_with?('Unexpected part')
+      error 400, 'Bad Request: fields in unacceptable order'
+    end
+
+    def forward
+      # The following is to authenticate the request arriving
+      # in Coquelicot::Application
+      @env['X_COQUELICOT_FORWARD'] = 'Yes'
+      super
+    end
+
+    def error_for_empty
+      # XXX: i18nize
+      error 403, 'File has no content'
+    end
+
+    # This will create a new (rewindable) input with the given params
+    def rewrite_input!(params)
+      @env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
+      data = Rack::Utils.build_nested_query(params)
+      env['rack.input'] = StringIO.new(data)
+    end
+  end
+end
index 8051bb0..30d4c64 100644 (file)
@@ -84,12 +84,14 @@ function authenticate() {
           /* Mh. Something strange happened. */
           return;
         }
+        var hiddenFields = $('<div />')
         $.each(authentication.getData(), function(key, value) {
           var hiddenField = $('<input type="hidden" />');
           hiddenField.attr('name', key);
           hiddenField.val(value);
-          $('#upload').append(hiddenField);
+          hiddenFields.append(hiddenField)
         });
+        $('#upload').prepend(hiddenFields);
         lb.close();
         if (authentication.handleAccept) {
           authentication.handleAccept();
diff --git a/spec/coquelicot/rack/multipart_parser_spec.rb b/spec/coquelicot/rack/multipart_parser_spec.rb
new file mode 100644 (file)
index 0000000..85fa23a
--- /dev/null
@@ -0,0 +1,426 @@
+# Coquelicot: "one-click" file sharing with a focus on users' privacy.
+# Copyright © 2012 potager.org <jardiniers@potager.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+require 'spec_helper'
+
+module Coquelicot::Rack
+  describe MultipartParser do
+    let(:env) { { 'SERVER_NAME' => 'example.org',
+                  'SERVER_PORT' => 80,
+                  'REQUEST_METHOD' => 'POST',
+                  'PATH_INFO' => '/upload',
+                  'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}",
+                  'CONTENT_LENGTH' => "#{defined?(input) ? input.size : 0}",
+                  'rack.input' => StringIO.new(defined?(input) ? input : '')
+                } }
+    describe '.parse' do
+      context 'when given a block taking one argument' do
+        it 'should run the block with a new parser as argument' do
+          MultipartParser.parse(env) do |p|
+            p.should be_a(MultipartParser)
+          end
+        end
+      end
+    end
+
+    describe '#start' do
+      context 'when given no block' do
+        it 'should raise an error' do
+          MultipartParser.parse(env) do |p|
+            expect { p.start }.to raise_exception(ArgumentError)
+          end
+        end
+      end
+      context 'when used once' do
+        it 'should call the block on start' do
+          mock = double
+          mock.should_receive(:act)
+          MultipartParser.parse(env) do |p|
+            p.start { mock.act }
+          end
+        end
+      end
+      context 'when used twice in a row' do
+        it 'should call both blocks on start' do
+          mock = double
+          mock.should_receive(:run).ordered
+          mock.should_receive(:walk).ordered
+          MultipartParser.parse(env) do |p|
+            p.start { mock.run }
+            p.start { mock.walk }
+          end
+        end
+      end
+      context 'when used twice with steps inbetween' do
+        it 'should call both blocks on start' do
+          mock = double
+          mock.should_receive(:run).ordered
+          mock.should_receive(:walk).ordered
+          MultipartParser.parse(env) do |p|
+            p.start { mock.run }
+            p.many_fields
+            p.start { mock.walk }
+          end
+        end
+      end
+    end
+
+    describe '#many_fields' do
+      let(:input) do <<-MULTIPART_DATA.gsub(/^ */, '').gsub(/\n/, "\r\n")
+          --AaB03x
+          Content-Disposition: form-data; name="one"
+
+          1
+          --AaB03x
+          Content-Disposition: form-data; name="two"
+
+          2
+          --AaB03x
+          Content-Disposition: form-data; name="three"
+
+          3
+          --AaB03x--
+        MULTIPART_DATA
+      end
+      context 'when used alone' do
+        it 'should call the given block only once' do
+          mock = double
+          mock.should_receive(:act).once
+          MultipartParser.parse(env) do |p|
+            p.many_fields do |params|
+              mock.act
+            end
+          end
+        end
+        it 'should call the given block for all fields' do
+          MultipartParser.parse(env) do |p|
+            p.many_fields do |params|
+              params.should == { 'one' => '1', 'two' => '2', 'three' => '3' }
+            end
+          end
+        end
+      end
+      context 'positioned after "field"' do
+        it 'should call the given block only once' do
+          mock = double
+          mock.should_receive(:act).once
+          MultipartParser.parse(env) do |p|
+            p.field :one
+            p.many_fields do |params|
+              mock.act
+            end
+          end
+        end
+        it 'should call the given block for the remaning fields' do
+          MultipartParser.parse(env) do |p|
+            p.field :one
+            p.many_fields do |params|
+              params.should == { 'two' => '2', 'three' => '3' }
+            end
+          end
+        end
+      end
+      context 'positioned before "field"' do
+        it 'should call the given block only once' do
+          mock = double
+          mock.should_receive(:act).once
+          MultipartParser.parse(env) do |p|
+            p.many_fields do |params|
+              mock.act
+            end
+            p.field :three
+          end
+        end
+        it 'should call the given block for the first two fields' do
+          MultipartParser.parse(env) do |p|
+            p.many_fields do |params|
+              params.should == { 'one' => '1', 'two' => '2' }
+            end
+            p.field :three
+          end
+        end
+      end
+      context 'before and after "field"' do
+        it 'should call each given block only once' do
+          mock = double
+          mock.should_receive(:run).ordered
+          mock.should_receive(:walk).ordered
+          MultipartParser.parse(env) do |p|
+            p.many_fields do |params|
+              mock.run
+            end
+            p.field :two
+            p.many_fields do |params|
+              mock.walk
+            end
+          end
+        end
+        it 'should call each given block for the first and last fields, respectively' do
+          MultipartParser.parse(env) do |p|
+            p.many_fields do |params|
+              params.should == { 'one' => '1' }
+            end
+            p.field :two
+            p.many_fields do |params|
+              params.should == { 'three' => '3' }
+            end
+          end
+        end
+      end
+    end
+    describe '#field' do
+      let(:input) do <<-MULTIPART_DATA.gsub(/^ */, '').gsub(/\n/, "\r\n")
+          --AaB03x
+          Content-Disposition: form-data; name="one"
+
+          1
+          --AaB03x
+          Content-Disposition: form-data; name="two"
+
+          2
+          --AaB03x
+          Content-Disposition: form-data; name="three"
+
+          3
+          --AaB03x--
+        MULTIPART_DATA
+      end
+      context 'when positioned like the request' do
+        it 'should call a block for each field' do
+          mock = double
+          mock.should_receive(:first).with('1').ordered
+          mock.should_receive(:second).with('2').ordered
+          mock.should_receive(:third).with('3').ordered
+          MultipartParser.parse(env) do |p|
+            p.field(:one)   { |value| mock.first  value }
+            p.field(:two)   { |value| mock.second value }
+            p.field(:three) { |value| mock.third  value }
+          end
+        end
+      end
+      context 'when request field does not match' do
+        it 'should issue an error' do
+          expect {
+            MultipartParser.parse(env) do |p|
+              p.field(:whatever)
+            end
+          }.to raise_exception(EOFError)
+        end
+      end
+      context 'when request field does not match after many_fields' do
+        it 'should not call the field block' do
+          mock = double
+          mock.should_not_receive(:foo)
+          MultipartParser.parse(env) do |p|
+            p.many_fields
+            p.field(:whatever) { mock.foo }
+          end
+        end
+      end
+      context 'when request field  match after many_fields' do
+        it 'should call the field block' do
+          mock = double
+          mock.should_receive(:foo).with('3')
+          MultipartParser.parse(env) do |p|
+            p.many_fields
+            p.field(:three) { |value| mock.foo(value) }
+          end
+        end
+      end
+    end
+    describe '#file' do
+      context 'when file is at the end of the request' do
+        let(:file) { __FILE__ }
+        let(:input) { Rack::Multipart::Generator.new(
+            'field1' => '1',
+            'field2' => '2',
+            'field3' => Rack::Multipart::UploadedFile.new(file)
+          ).dump }
+        context 'when positioned like the request' do
+          it 'should call the given block in the right order' do
+            mock = double
+            mock.should_receive(:first).ordered
+            mock.should_receive(:second).ordered
+            mock.should_receive(:third).ordered
+            MultipartParser.parse(env) do |p|
+              p.field(:field1) { |value| mock.first }
+              p.field(:field2) { |value| mock.second }
+              p.file(:field3) do |filename, content_type, reader|
+                mock.third
+                while reader.call; end # flush file data
+              end
+            end
+          end
+          it 'should call the block passing the filename' do
+            filename = File.basename(file)
+            MultipartParser.parse(env) do |p|
+              p.many_fields
+              p.file(:field3) do |filename, content_type, reader|
+                filename.should == filename
+                while reader.call; end # flush file data
+              end
+            end
+          end
+          it 'should call the block passing the content type' do
+            MultipartParser.parse(env) do |p|
+              p.many_fields
+              p.file(:field3) do |filename, content_type, reader|
+                content_type.should == 'text/plain'
+                while reader.call; end # flush file data
+              end
+            end
+          end
+          it 'should read the whole file with multiple reader.call' do
+            data = ''
+            MultipartParser.parse(env) do |p|
+              p.many_fields
+              p.file(:field3) do |filename, content_type, reader|
+                buf = ''
+                data << buf until (buf = reader.call).nil?
+              end
+            end
+            data.should == File.read(file)
+          end
+        end
+      end
+
+      context 'when file is at the middle of the request' do
+        let(:file) { __FILE__ }
+        let(:input) { Rack::Multipart::Generator.new(
+            'field1' => '1',
+            'field2' => Rack::Multipart::UploadedFile.new(file),
+            'field3' => '3'
+          ).dump }
+        context 'when positioned like the request' do
+          it 'should call the given block in the right order' do
+            mock = double
+            mock.should_receive(:first).ordered
+            mock.should_receive(:second).ordered
+            mock.should_receive(:third).ordered
+            MultipartParser.parse(env) do |p|
+              p.field(:field1) { |value| mock.first }
+              p.file(:field2) do |filename, content_type, reader|
+                mock.second
+                while reader.call; end # flush file data
+              end
+              p.field(:field3) { |value| mock.third }
+            end
+          end
+          it 'should read the whole file with multiple reader.call' do
+            data = ''
+            MultipartParser.parse(env) do |p|
+              p.field(:field1)
+              p.file(:field2) do |filename, content_type, reader|
+                buf = ''
+                data << buf until (buf = reader.call).nil?
+              end
+              p.field(:field3)
+            end
+            data.should == File.read(file)
+          end
+        end
+      end
+      context 'when there two files follow each others in the request' do
+        let(:file1) { __FILE__ }
+        let(:file2) { File.expand_path('../../../spec_helper.rb', __FILE__) }
+        let(:input) { Rack::Multipart::Generator.new(
+            'field1' => Rack::Multipart::UploadedFile.new(file1),
+            'field2' => Rack::Multipart::UploadedFile.new(file2)
+          ).dump }
+        context 'when positioned like the request' do
+          it 'should call the given block in the right order' do
+            mock = double
+            mock.should_receive(:first).ordered
+            mock.should_receive(:second).ordered
+            MultipartParser.parse(env) do |p|
+              p.file(:field1) do |filename, content_type, reader|
+                mock.first
+                while reader.call; end # flush file data
+              end
+              p.file(:field2) do |filename, content_type, reader|
+                mock.second
+                buf = ''
+                while reader.call; end # flush file data
+              end
+            end
+          end
+          it 'should read the files correctly' do
+            filename1 = File.basename(file1)
+            filename2 = File.basename(file2)
+            data1 = ''
+            data2 = ''
+            MultipartParser.parse(env) do |p|
+              p.file(:field1) do |filename, content_type, reader|
+                filename.should == filename1
+                buf = ''
+                data1 << buf until (buf = reader.call).nil?
+              end
+              p.file(:field2) do |filename, content_type, reader|
+                filename.should == filename2
+                buf = ''
+                data2 << buf until (buf = reader.call).nil?
+              end
+            end
+            data1.should == File.read(file1)
+            data2.should == File.read(file2)
+          end
+        end
+      end
+    end
+
+    describe '#finish' do
+      context 'when given no block' do
+        it 'should raise an error' do
+          MultipartParser.parse(env) do |p|
+            expect { p.finish }.to raise_exception(ArgumentError)
+          end
+        end
+      end
+      context 'when used once' do
+        it 'should call the block on finish' do
+          mock = mock('Object')
+          mock.should_receive(:act)
+          MultipartParser.parse(env) do |p|
+            p.finish { mock.act }
+          end
+        end
+      end
+      context 'when used twice in a row' do
+        it 'should call both blocks on finish (in reverse order)' do
+          mock = double
+          mock.should_receive(:run).ordered
+          mock.should_receive(:walk).ordered
+          MultipartParser.parse(env) do |p|
+            p.finish { mock.walk }
+            p.finish { mock.run }
+          end
+        end
+      end
+      context 'when used twice with steps inbetween' do
+        it 'should call both blocks on finish (in reverse order)' do
+          mock = double
+          mock.should_receive(:run).ordered
+          mock.should_receive(:walk).ordered
+          MultipartParser.parse(env) do |p|
+            p.finish { mock.walk }
+            p.many_fields
+            p.finish { mock.run }
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/spec/coquelicot/rack/upload_spec.rb b/spec/coquelicot/rack/upload_spec.rb
new file mode 100644 (file)
index 0000000..7c69360
--- /dev/null
@@ -0,0 +1,280 @@
+# Coquelicot: "one-click" file sharing with a focus on users' privacy.
+# Copyright © 2012 potager.org <jardiniers@potager.org>
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as
+# published by the Free Software Foundation, either version 3 of the
+# License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+require 'spec_helper'
+require 'multipart_parser/reader'
+
+module Coquelicot::Rack
+  # Helpers method to have more readable code to test Rack responses
+  module RackResponse
+    def status; self[0]; end
+    def headers; self[1]; end
+    def body; buf = ''; self[2].each { |l| buf << l }; buf; end
+  end
+
+  describe Upload do
+
+    include_context 'with Coquelicot::Application'
+
+    let(:lower_app) { lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['Lower']] } }
+    let(:upload) { Upload.new(lower_app) }
+    describe '#call' do
+      subject { upload.call(env).extend(RackResponse) }
+      context 'when receiving GET /' do
+        let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/' } }
+        it 'should pass the request to the lower app' do
+          subject.body.should == 'Lower'
+        end
+        it 'should ensure the forwarded rack.input is rewindable' do
+          spec_app = double
+          spec_app.should_receive(:call) do |env|
+            env['rack.input'].should respond_to(:rewind)
+            [200, {'Content-Type' => 'text/plain'}, ['mock']]
+          end
+          input = StringIO.new('foo=bar&quux=blabb')
+          class << input; undef_method(:rewind); end
+          env['rack.input'] = input
+          Upload.new(spec_app).call(env)
+        end
+      end
+      context 'when called for GET /upload' do
+        let(:env) { { 'REQUEST_METHOD' => 'GET', 'PATH_INFO' => '/upload' } }
+        it 'should pass the request to the lower app' do
+          subject.body.should == 'Lower'
+        end
+      end
+      context 'when called for POST /upload' do
+        let(:env) { { 'SERVER_NAME' => 'example.org',
+                      'SERVER_PORT' => 80,
+                      'REQUEST_METHOD' => 'POST',
+                      'PATH_INFO' => '/upload',
+                      'CONTENT_TYPE' => "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}",
+                      'CONTENT_LENGTH' => "#{input.size}",
+                      'rack.input' => StringIO.new(input)
+                    } }
+        context 'when rack.input is rewindable' do
+          let(:input) { '' }
+          it 'should log a warning during the first request' do
+            logger = double('Logger')
+            logger.should_receive(:warn).with(/rewindable/).once
+            env['rack.logger'] = logger
+            # set it to nil to stop Sinatra from messing up
+            upload = Class.new(Upload) { set :logging, nil }.new(lower_app)
+            upload.call(env.dup)
+            # second request, to be sure the warning will show up only once
+            upload.call(env)
+          end
+        end
+        context 'when receiving a request which is not multipart' do
+          let(:input) { 'foo=bar&quux=blabb' }
+          it 'should raise an error' do
+            env['CONTENT_TYPE'] = 'application/x-www-form-urlencoded'
+            expect { subject }.to raise_exception(::MultipartParser::NotMultipartError)
+          end
+        end
+
+        shared_context 'correct POST data' do
+          let(:file) { File.expand_path('../../../spec_helper.rb', __FILE__) }
+          let(:file_content) { File.read(file) }
+          let(:file_key) { 'secret' }
+          let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n") % file_content
+--AaB03x
+Content-Disposition: form-data; name="upload_password"
+
+whatever
+--AaB03x
+Content-Disposition: form-data; name="expire"
+
+60
+--AaB03x
+Content-Disposition: form-data; name="file_key"
+
+#{file_key}
+--AaB03x
+Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
+Content-Type: text/plain
+
+%s
+--AaB03x
+Content-Disposition: form-data; name="submit"
+
+submit
+--AaB03x--
+MULTIPART_DATA
+          end
+        end
+
+        context 'when options are correct' do
+          include_context 'correct POST data'
+          before(:each) do
+            Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
+          end
+          it 'should issue a temporary redirect' do
+            subject.status.should satisfy{|s| [302,303].include?(s) }
+          end
+          it 'should redirect to the ready page' do
+            subject.headers['Location'].should =~ %r{http://example\.org/ready/}
+          end
+          it 'should add a file to the depot' do
+            filename = File.basename(file)
+            Coquelicot.depot.should_receive(:add_file).
+                with(file_key, hash_including('Filename' => filename)).
+                and_yield(file_content).and_yield(nil)
+            subject
+          end
+          it 'should increment the depot size' do
+            expect { subject }.to change { Coquelicot.depot.size }.by(1)
+          end
+        end
+        context 'when receiving a request with other fields after file' do
+          before(:each) do
+            Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
+          end
+          let(:file) { File.expand_path('../../../spec_helper.rb', __FILE__) }
+          let(:file_content) { File.read(file) }
+          let(:file_key) { 'secret' }
+          let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n") % file_content
+--AaB03x
+Content-Disposition: form-data; name="upload_password"
+
+whatever
+--AaB03x
+Content-Disposition: form-data; name="file_key"
+
+#{file_key}
+--AaB03x
+Content-Disposition: form-data; name="file"; filename="#{File.basename(file)}"
+Content-Type: text/plain
+
+%s
+--AaB03x
+Content-Disposition: form-data; name="submit"
+
+submit
+--AaB03x
+Content-Disposition: form-data; name="i_should_not_appear_here"
+
+whatever
+--AaB03x--
+MULTIPART_DATA
+          end
+          it 'should bail out with code 400 (Bad Request)' do
+            subject.status == 400
+          end
+          it 'should display "Bad Request: fields in unacceptable order"' do
+            subject.body.should == 'Bad Request: fields in unacceptable order'
+          end
+        end
+        context 'when authentication fails' do
+          include_context 'correct POST data'
+          before(:each) do
+            Coquelicot.settings.authenticator.stub(:authenticate).and_return(false)
+          end
+          it 'should bail out with code 403 (Forbidden)' do
+            subject.status == 403
+          end
+          it 'should display "Forbidden"' do
+            subject.body.should == 'Forbidden'
+          end
+          it 'should not add a file' do
+            expect { subject }.to_not change { Coquelicot.depot.size }
+          end
+        end
+        context 'when authentication is impossible' do
+          include_context 'correct POST data'
+          before(:each) do
+            Coquelicot.settings.authenticator.stub(:authenticate).and_raise(
+              Coquelicot::Auth::Error.new('Something bad happened!'))
+          end
+          it 'should bail out with code 503 (Service Unavailable)' do
+            subject.status == 503
+          end
+          it 'should display the error message' do
+            subject.body.should == 'Something bad happened!'
+          end
+          it 'should not add a file' do
+            expect { subject }.to_not change { Coquelicot.depot.size }
+          end
+        end
+        context 'when no file has been submitted' do
+          let(:input) do <<MULTIPART_DATA.gsub(/\n/, "\r\n")
+--AaB03x
+Content-Disposition: form-data; name="upload_password"
+
+whatever
+--AaB03x
+Content-Disposition: form-data; name="expire"
+
+60
+--AaB03x
+Content-Disposition: form-data; name="one_time"
+
+true
+--AaB03x
+Content-Disposition: form-data; name="submit"
+
+submit
+--AaB03x--
+MULTIPART_DATA
+          end
+          before(:each) do
+            Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
+          end
+          it 'should pass to the lower app' do
+            subject.body.should == 'Lower'
+          end
+          it 'should set X_COQUELICOT_FORWARD in env' do
+            mock_app = double
+            mock_app.should_receive(:call).
+                with(hash_including('X_COQUELICOT_FORWARD')).
+                and_return([200, {'Content-Type' => 'text/plain'}, ['forward mock']])
+            Upload.new(mock_app).call(env)
+          end
+          it 'should forward interesting params' do
+            mock_app = double
+            mock_app.should_receive(:call) do
+              request = Sinatra::Request.new(env)
+              request.params['upload_password'].should == 'whatever'
+              request.params['expire'].should == '60'
+              request.params['one_time'].should == 'true'
+              [200, {'Content-Type' => 'text/plain'}, ['forward mock']]
+            end
+            Upload.new(mock_app).call(env)
+          end
+          it 'should not add a file' do
+            expect { subject }.to_not change { Coquelicot.depot.size }
+          end
+        end
+        context 'when the expiration time is bigger than allowed' do
+          include_context 'correct POST data'
+          before(:each) do
+            Coquelicot.settings.authenticator.stub(:authenticate).and_return(true)
+            Coquelicot.settings.stub(:maximum_expire).and_return(5)
+          end
+          it 'should bail out with 403 (Forbidden)' do
+            subject.status == 403
+          end
+          it 'should display "Forbidden: expiration time too big"' do
+            subject.body.should == 'Forbidden: expiration time too big'
+          end
+          it 'should not add a file' do
+            expect { subject }.to_not change { Coquelicot.depot.size }
+          end
+        end
+      end
+    end
+  end
+end
index d6d8330..5e6bcde 100644 (file)
@@ -19,6 +19,7 @@ require 'spec_helper'
 require 'timecop'
 require 'hpricot'
 require 'tmpdir'
+require 'active_support'
 
 UPLOAD_PASSWORD = 'secret'
 
@@ -35,10 +36,19 @@ describe 'Coquelicot' do
   include_context 'with Coquelicot::Application'
 
   def upload(opts={})
-    opts = { :file => Rack::Test::UploadedFile.new(__FILE__, 'text/x-script.ruby'),
-             :upload_password => UPLOAD_PASSWORD
-           }.merge(opts)
-    post '/upload', opts
+    # We need the request to be in the right order
+    params = ActiveSupport::OrderedHash.new
+    params[:upload_password] = UPLOAD_PASSWORD
+    params[:expire] = 5
+    params[:one_time] = ''
+    params[:file_key] = ''
+    params[:file] = Rack::Test::UploadedFile.new(__FILE__, 'text/x-script.ruby')
+    params.merge!(opts)
+    data = build_multipart(params)
+    post '/upload', {}, { :input           => data,
+                          'CONTENT_LENGTH' => data.length.to_s,
+                          'CONTENT_TYPE'   => "multipart/form-data; boundary=#{Rack::Multipart::MULTIPART_BOUNDARY}"
+                        }
     return nil unless last_response.redirect?
     follow_redirect!
     last_response.should be_ok
@@ -47,6 +57,28 @@ describe 'Coquelicot' do
              select { |h| h.start_with? "http://#{last_request.host}/" }[0]
   end
 
+  def build_multipart(params)
+    params.map do |name, value|
+      if value.is_a? Rack::Test::UploadedFile
+        <<-PART
+--#{Rack::Multipart::MULTIPART_BOUNDARY}\r
+Content-Disposition: form-data; name="#{name}"; filename="#{Rack::Utils.escape(value.original_filename)}"\r
+Content-Type: #{value.content_type}\r
+Content-Length: #{::File.stat(value.path).size}\r
+\r
+#{File.open(value.path).read}\r
+PART
+      else
+        <<-PART
+--#{Rack::Multipart::MULTIPART_BOUNDARY}\r
+Content-Disposition: form-data; name="#{name}"\r
+\r
+#{value}\r
+PART
+      end
+    end.join + "--#{Rack::Multipart::MULTIPART_BOUNDARY}--\r"
+  end
+
   it "should offer an upload form" do
     get '/'
     last_response.should be_ok
@@ -120,6 +152,11 @@ describe 'Coquelicot' do
           last_response.body.should eql(File.new(__FILE__).read)
         end
 
+        it "should have sent the right Content-Length" do
+          last_response.should be_ok
+          last_response['Content-Length'].to_i.should == File.stat(__FILE__).size
+        end
+
         it "should always has the same Last-Modified header" do
           last_modified = last_response['Last-Modified']
           last_modified.should_not be_nil
index a34c32b..27eba3b 100644 (file)
@@ -29,9 +29,6 @@
     %script{ :type => 'text/javascript', :src => "javascripts/coquelicot.auth.#{auth_method}.js" }
     = render :haml, :"auth/#{auth_method}", :layout => false
   .field
-    %label{ :for => 'file' } File:
-    %input.input{ :type => 'file', :id => 'file', :name => 'file' }
-  .field
     %label{ :for => 'expire' } Available for:
     %select.input{ :id => 'expire',:name => 'expire' }
       %option{ :value => 5            } 5 minutes
@@ -51,5 +48,8 @@
     %label{ :for => 'file_key' } Download password:
     %input.input{ :type => 'password', :id => 'file_key', :name => 'file_key' }
   .field
+    %label{ :for => 'file' } File:
+    %input.input{ :type => 'file', :id => 'file', :name => 'file' }
+  .field
     .submit
       %input.submit{ :type => 'submit', :value => _('Share!') }