aboutsummaryrefslogtreecommitdiffstats
path: root/actionpack/lib/action_controller/cgi_ext/multipart_progress.rb
blob: d297c6f86ee92753083ed88a18605c0eb6497596 (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
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
# == Overview
#
# This module will extend the CGI module with methods to track the upload
# progress for multipart forms for use with progress meters.  The progress is
# saved in the session to be used from any request from any server with the
# same session.  In other words, this module will work across application
# instances.
#
# === Usage
#
# Just do your file-uploads as you normally would, but include an upload_id in
# the query string of your form action.  Your form post action should look
# like:
#
#   <form method="post" enctype="multipart/form-data" action="postaction?upload_id=SOMEIDYOUSET">
#     <input type="file" name="client_file"/>
#   </form>
#
# Query the upload state in a progress by reading the progress from the session
#
#   class UploadController < ApplicationController
#     def upload_status
#       render :text => "Percent complete: " + @session[:uploads]['SOMEIDYOUSET'].completed_percent"
#     end
#   end
#
# === Session options
#
# Upload progress uses the session options defined in 
# ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS.  If you are passing
# custom session options to your dispatcher then please follow the
# "recommended way to change session options":http://wiki.rubyonrails.com/rails/show/HowtoChangeSessionOptions
#
# === Update frequency
#
# During an upload, the progress will be written to the session every 2
# seconds.  This prevents excessive writes yet maintains a decent picture of
# the upload progress for larger files.  
#
# User interfaces that update more often that every 2 seconds will display the same results.
# Consider this update frequency when designing your progress polling.
#

require 'cgi'

# For integration with ActionPack
require 'action_controller/base'
require 'action_controller/cgi_process'
require 'action_controller/upload_progress'

class CGI #:nodoc:
  class ProgressIO < SimpleDelegator #:nodoc:
    MIN_SAVE_INTERVAL = 1.0         # Number of seconds between session saves

    attr_reader :progress, :session

    def initialize(orig_io, progress, session)
      @session = session
      @progress = progress
      
      @start_time = Time.now
      @last_save_time = @start_time
      save_progress
      
      super(orig_io)
    end

    def read(*args)
      data = __getobj__.read(*args)

      if data and data.size > 0
        now = Time.now
        elapsed =  now - @start_time
        progress.update!(data.size, elapsed)

        if now - @last_save_time > MIN_SAVE_INTERVAL
          save_progress 
          @last_save_time = now 
        end
      else
        ActionController::Base.logger.debug("CGI::ProgressIO#read returns nothing when it should return nil if IO is finished: [#{args.inspect}], a cancelled upload or old FCGI bindings.  Resetting the upload progress")

        progress.reset!
        save_progress
      end

      data
    end

    def save_progress
      @session.update 
    end

    def finish
      @session.update
      ActionController::Base.logger.debug("Finished processing multipart upload in #{@progress.elapsed_seconds.to_s}s")
    end
  end

  module QueryExtension #:nodoc:
    # Need to do lazy aliasing on the instance that we are extending because of the way QueryExtension
    # gets included for each instance of the CGI object rather than on a module level.  This method is a 
    # bit obtrusive because we are overriding CGI::QueryExtension::extended which could be used in the 
    # future.  Need to research a better method
    def self.extended(obj)
      obj.instance_eval do
        # unless defined? will prevent clobbering the progress IO on multiple extensions
        alias :stdinput_without_progress :stdinput unless defined? stdinput_without_progress
        alias :stdinput :stdinput_with_progress 
      end
    end

    def stdinput_with_progress
      @stdin_with_progress or stdinput_without_progress
    end

    private
    # Bootstrapped on ActionController::UploadProgress::upload_status_for
    def read_multipart_with_progress(boundary, content_length)
      begin
        begin
          # Session disabled if the default session options have been set to 'false'
          options = ActionController::CgiRequest::DEFAULT_SESSION_OPTIONS
          raise RuntimeError.new("Multipart upload progress disabled, no session options") unless options

          options = options.stringify_keys
       
          #Controllers.const_load!(:ApplicationController, "application") unless Controllers.const_defined?(:ApplicationController)

          # Assumes that @cookies has already been setup
          # Raises nomethod if upload_id is not defined
          @params = CGI::parse(read_params_from_query)
          upload_id = @params[(options['upload_key'] || 'upload_id')].first
          raise RuntimeError.new("Multipart upload progress disabled, no upload id in query string") unless upload_id

          upload_progress = ActionController::UploadProgress::Progress.new(content_length)

          session = Session.new(self, options)
          session[:uploads] = {} unless session[:uploads]
          session[:uploads].delete(upload_id) # in case the same upload id is used twice
          session[:uploads][upload_id] = upload_progress

          @stdin_with_progress = CGI::ProgressIO.new(stdinput_without_progress, upload_progress, session)
          ActionController::Base.logger.debug("Multipart upload with progress (id: #{upload_id}, size: #{content_length})")
        rescue
          ActionController::Base.logger.debug("Exception during setup of read_multipart_with_progress: #{$!}")
        end
      ensure
        begin
          params = read_multipart_without_progress(boundary, content_length)
          @stdin_with_progress.finish if @stdin_with_progress.respond_to? :finish
        ensure
          @stdin_with_progress = nil
          session.close if session
        end
      end
      params 
    end

    # Prevent redefinition of aliases on multiple includes
    unless private_instance_methods.include?("read_multipart_without_progress")
      alias_method :read_multipart_without_progress, :read_multipart 
      alias_method :read_multipart, :read_multipart_with_progress
    end

  end
end