diff options
Diffstat (limited to 'actionpack/lib/action_controller')
-rw-r--r-- | actionpack/lib/action_controller/cgi_ext/multipart_progress.rb | 167 | ||||
-rw-r--r-- | actionpack/lib/action_controller/upload_progress.rb | 410 |
2 files changed, 577 insertions, 0 deletions
diff --git a/actionpack/lib/action_controller/cgi_ext/multipart_progress.rb b/actionpack/lib/action_controller/cgi_ext/multipart_progress.rb new file mode 100644 index 0000000000..d297c6f86e --- /dev/null +++ b/actionpack/lib/action_controller/cgi_ext/multipart_progress.rb @@ -0,0 +1,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 diff --git a/actionpack/lib/action_controller/upload_progress.rb b/actionpack/lib/action_controller/upload_progress.rb new file mode 100644 index 0000000000..ce8d51c67d --- /dev/null +++ b/actionpack/lib/action_controller/upload_progress.rb @@ -0,0 +1,410 @@ +# Unfortunately we need to require multipart_progress here and not in +# uplaod_status_for because if the upload happens to hit a fresh FCGI instance +# the upload_status_for method will be called after the CGI object is created +# Requiring here means that multipart progress will be enabled for all multipart +# postings. +require 'action_controller/cgi_ext/multipart_progress' + +module ActionController #:nodoc: + # == Action Pack Upload Progress for multipart uploads + # + # The UploadProgress module aids in the process of viewing an Ajax driven + # upload status when working with multipart forms. It offers a macro that + # will prepare an action for handling the cleanup of the Ajax updating including + # passing the redirect URL and custom parameters to the Javascript finish handler. + # + # UploadProgress is available for all multipart uploads when the +upload_status_for+ + # macro is called in one of your controllers. + # + # The progress is stored as an UploadProgress::Progress object in the session and + # is accessible in the controller and view with the +upload_progress+ method. + # + # For help rendering the UploadProgress enabled form and supported elements, see + # ActionView::Helpers::UploadProgressHelper. + # + # === Automatic updating on upload actions + # + # class DocumentController < ApplicationController + # upload_status_for :create + # + # def create + # # ... Your document creation action + # end + # end + # + # The +upload_status_for+ macro will override the rendering of the action passed + # if +upload_id+ is found in the query string. This allows for default + # behavior if Javascript is disabled. If you are tracking the upload progress + # then +create+ will now return the cleanup scripts that will terminate the polling + # of the upload status. + # + # === Customized status rendering + # + # class DocumentController < ApplicationController + # upload_status_for :create, :status => :custom_status + # + # def create + # # ... Your document creation action + # end + # + # def custom_status + # # ... Override this action to return content to be replaced in + # # the status container + # render :inline => "<%= upload_progress.completed_percent rescue 0 %> % complete", :layout => false + # end + # + # The default status action is +upload_status+. The results of this action + # are added used to replace the contents of the HTML elements defined in + # +upload_status_tag+. Within +upload_status+, you can load the Progress + # object from the session with the +upload_progress+ method and display your own + # results. + # + # Completion of the upload status updating occurs automatically with an +after_filter+ call to + # +finish_upload_status+. Because the upload must be posted into a hidden IFRAME to enable + # Ajax updates during the upload, +finish_upload_status+ overwrites the results of any previous + # +render+ or +redirect_to+ so it can render the necessary Javascript that will properly terminate + # the status updating loop, trigger the completion callback or redirect to the appropriate URL. + # + # ==== Basic Example (View): + # + # <%= form_tag_with_upload_progress({:action => 'create'}, {:finish => 'alert("Document Uploaded")'}) %> + # <%= upload_status_tag %> + # <%= file_field 'document', 'file' %> + # <%= end_form_tag %> + # + # ==== Basic Example (Controller): + # + # class DocumentController < ApplicationController + # upload_status_for :create + # + # def create + # @document = Document.create(params[:document]) + # end + # end + # + # ==== Extended Example (View): + # + # <%= form_tag_with_upload_progress({:action => 'create'}, {}, {:action => :custom_status}) %> + # <%= upload_status_tag %> + # <%= file_field 'document', 'file' %> + # <%= submit_tag "Upload" %> + # <%= end_form_tag %> + # + # <%= form_tag_with_upload_progress({:action => 'add_preview'}, {:finish => 'alert(arguments[0])'}, {:action => :custom_status}) %> + # <%= upload_status_tag %> + # <%= submit_tag "Upload" %> + # <%= file_field 'preview', 'file' %> + # <%= end_form_tag %> + # + # ==== Extended Example (Controller): + # + # class DocumentController < ApplicationController + # upload_status_for :add_preview, :create, {:status => :custom_status} + # + # def add_preview + # @document = Document.find(params[:id]) + # @document.preview = Preview.create(params[:preview]) + # if @document.save + # finish_upload_status "'Preview added'" + # else + # finish_upload_status "'Preview not added'" + # end + # end + # + # def create + # @document = Document.new(params[:document]) + # + # upload_progress.message = "Processing document..." + # session.update + # + # @document.save + # redirect_to :action => 'show', :id => @document.id + # end + # + # def custom_status + # render :inline => '<%= upload_progress_status %> <div>Updated at <%= Time.now %></div>', :layout => false + # end + # + # + module UploadProgress + + def self.append_features(base) #:nodoc: + super + base.extend(ClassMethods) + base.helper_method :upload_progress, :next_upload_id, :last_upload_id, :current_upload_id + end + + module ClassMethods + # Creates an +after_filter+ which will call +finish_upload_status+ + # creating the document that will be loaded into the hidden IFRAME, terminating + # the status polling forms created with +form_with_upload_progress+. + # + # Also defines an action +upload_status+ or a action name passed as + # the <tt>:status</tt> option. This status action must match the one expected + # in the +form_tag_with_upload_progress+ helper. + # + def upload_status_for(*actions) + after_filter :finish_upload_status, :only => actions + + define_method(actions.last.is_a?(Hash) && actions.last[:status] || :upload_status) do + render(:inline => '<%= upload_progress_status %>', :layout => false) + end + end + end + + # Overwrites the body rendered if the upload comes from a form that tracks + # the progress of the upload. After clearing the body and any redirects, this + # method then renders the helper +finish_upload_status+ + # + # This method only needs to be called if you wish to pass a + # javascript parameter to your finish event handler that you optionally + # define in +form_with_upload_progress+ + # + # === Parameter: + # + # client_js_argument:: a string containing a Javascript expression that will + # be evaluated and passed to your +finish+ handler of + # +form_tag_with_upload_progress+. + # + # You can pass a String, Number or Boolean. + # + # === Strings + # + # Strings contain Javascript code that will be evaluated on the client. If you + # wish to pass a string to the client finish callback, you will need to include + # quotes in the +client_js_argument+ you pass to this method. + # + # ==== Example + # + # finish_upload_status("\"Finished\"") + # finish_upload_status("'Finished #{@document.title}'") + # finish_upload_status("{success: true, message: 'Done!'}") + # finish_upload_status("function() { alert('Uploaded!'); }") + # + # === Numbers / Booleans + # + # Numbers and Booleans can either be passed as Number objects or string versions + # of number objects as they are evaluated by Javascript the same way as in Ruby. + # + # ==== Example + # + # finish_upload_status(0) + # finish_upload_status(@document.file.size) + # finish_upload_status("10") + # + # === Nil + # + # To pass +nil+ to the finish callback, use a string "undefined" + # + # ==== Example + # + # finish_upload_status(@message || "undefined") + # + # == Redirection + # + # If you action performs a redirection then +finish_upload_status+ will recognize + # the redirection and properly create the Javascript to perform the redirection in + # the proper location. + # + # It is possible to redirect and pass a parameter to the finish callback. + # + # ==== Example + # + # redirect_to :action => 'show', :id => @document.id + # finish_upload_status("'Redirecting you to your new file'") + # + # + def finish_upload_status(client_js_argument='') + if not @rendered_finish_upload_status and params[:upload_id] + @rendered_finish_upload_status = true + + erase_render_results + location = erase_redirect_results || '' + + ## TODO determine if #inspect is the appropriate way to marshall values + ## in inline templates + + template = "<%= finish_upload_status({" + template << ":client_js_argument => #{client_js_argument.inspect}, " + template << ":redirect_to => #{location.to_s.inspect}, " + template << "}) %>" + + render({ :inline => template, :layout => false }) + end + end + + # Returns and saves the next unique +upload_id+ in the instance variable + # <tt>@upload_id</tt> + def next_upload_id + @upload_id = last_upload_id.succ + end + + # Either returns the last saved +upload_id+ or looks in the session + # for the last used +upload_id+ and saves it as the intance variable + # <tt>@upload_id</tt> + def last_upload_id + @upload_id ||= ((session[:uploads] || {}).keys.map{|k| k.to_i}.sort.last || 0).to_s + end + + # Returns the +upload_id+ from the query parameters or if it cannot be found + # in the query parameters, then return the +last_upload_id+ + def current_upload_id + params[:upload_id] or last_upload_id + end + + # Get the UploadProgress::Progress object for the supplied +upload_id+ from the + # session. If no +upload_id+ is given, then use the +current_upload_id+ + # + # If an UploadProgress::Progress object cannot be found, a new instance will be + # returned with <code>total_bytes == 0</code>, <code>started? == false</code>, + # and <code>finished? == true</code>. + def upload_progress(upload_id = nil) + upload_id ||= current_upload_id + session[:uploads] && session[:uploads][upload_id] || UploadProgress::Progress.new(0) + end + + # Upload Progress abstracts the progress of an upload. It's used by the + # multipart progress IO that keeps track of the upload progress and creating + # the application depends on. It contians methods to update the progress + # during an upload and read the statistics such as +received_bytes+, + # +total_bytes+, +completed_percent+, +bitrate+, and + # +remaining_seconds+ + # + # You can get the current +Progress+ object by calling +upload_progress+ instance + # method in your controller or view. + # + class Progress + unless const_defined? :MIN_SAMPLE_TIME + # Number of seconds between bitrate samples. Updates that occur more + # frequently than +MIN_SAMPLE_TIME+ will not be queued until this + # time passes. This behavior gives a good balance of accuracy and load + # for both fast and slow transfers. + MIN_SAMPLE_TIME = 0.150 + + # Number of seconds between updates before giving up to try and calculate + # bitrate anymore + MIN_STALL_TIME = 10.0 + + # Number of samples used to calculate bitrate + MAX_SAMPLES = 20 + end + + # Number bytes received from the multipart post + attr_reader :received_bytes + + # Total number of bytes expected from the mutlipart post + attr_reader :total_bytes + + # The last time the upload history was updated + attr_reader :last_update_time + + # A message you can set from your controller or view to be rendered in the + # +upload_status_text+ helper method. If you set a messagein a controller + # then call <code>session.update</code> to make that message available to + # your +upload_status+ action. + attr_accessor :message + + # Create a new Progress object passing the expected number of bytes to receive + def initialize(total) + @total_bytes = total + reset! + end + + # Resets the received_bytes, last_update_time, message and bitrate, but + # but maintains the total expected bytes + def reset! + @received_bytes, @last_update_time, @stalled, @message = 0, 0, false, '' + reset_history + end + + # Number of bytes left for this upload + def remaining_bytes + @total_bytes - @received_bytes + end + + # Completed percent in integer form from 0..100 + def completed_percent + (@received_bytes * 100 / @total_bytes).to_i rescue 0 + end + + # Updates this UploadProgress object with the number of bytes received + # since last update time and the absolute number of seconds since the + # beginning of the upload. + # + # This method is used by the +MultipartProgress+ module and should + # not be called directly. + def update!(bytes, elapsed_seconds)#:nodoc: + if @received_bytes + bytes > @total_bytes + #warn "Progress#update received bytes exceeds expected bytes" + bytes = @total_bytes - @received_bytes + end + + @received_bytes += bytes + + # Age is the duration of time since the last update to the history + age = elapsed_seconds - @last_update_time + + # Record the bytes received in the first element of the history + # in case the sample rate is exceeded and we shouldn't record at this + # time + @history.first[0] += bytes + @history.first[1] += age + + history_age = @history.first[1] + + @history.pop while @history.size > MAX_SAMPLES + @history.unshift([0,0]) if history_age > MIN_SAMPLE_TIME + + if history_age > MIN_STALL_TIME + @stalled = true + reset_history + else + @stalled = false + end + + @last_update_time = elapsed_seconds + + self + end + + # Calculates the bitrate in bytes/second. If the transfer is stalled or + # just started, the bitrate will be 0 + def bitrate + history_bytes, history_time = @history.transpose.map { |vals| vals.inject { |sum, v| sum + v } } + history_bytes / history_time rescue 0 + end + + # Number of seconds elapsed since the start of the upload + def elapsed_seconds + @last_update_time + end + + # Calculate the seconds remaining based on the current bitrate. Returns + # O seconds if stalled or if no bytes have been received + def remaining_seconds + remaining_bytes / bitrate rescue 0 + end + + # Returns true if there are bytes pending otherwise returns false + def finished? + remaining_bytes <= 0 + end + + # Returns true if some bytes have been received + def started? + @received_bytes > 0 + end + + # Returns true if there has been a delay in receiving bytes. The delay + # is set by the constant MIN_STALL_TIME + def stalled? + @stalled + end + + private + def reset_history + @history = [[0,0]] + end + end + end +end |