Merge branch 'master' into 3219-further-docker-improvements
[arvados.git] / apps / workbench / app / controllers / application_controller.rb
index 3a9ccf2044e0bdb0f486e5a90a90004944875733..f739ee1046bb569f6e55bd4aef72cbd786d7fc26 100644 (file)
@@ -8,24 +8,24 @@ class ApplicationController < ActionController::Base
   ERROR_ACTIONS = [:render_error, :render_not_found]
 
   around_filter :thread_clear
-  around_filter :thread_with_mandatory_api_token, except: ERROR_ACTIONS
-  around_filter :thread_with_optional_api_token
+  around_filter :set_thread_api_token
+  # Methods that don't require login should
+  #   skip_around_filter :require_thread_api_token
+  around_filter :require_thread_api_token, except: ERROR_ACTIONS
   before_filter :check_user_agreements, except: ERROR_ACTIONS
   before_filter :check_user_notifications, except: ERROR_ACTIONS
-  before_filter :find_object_by_uuid, except: [:index] + ERROR_ACTIONS
+  before_filter :find_object_by_uuid, except: [:index, :choose] + ERROR_ACTIONS
   theme :select_theme
 
   begin
-    rescue_from Exception,
-    :with => :render_exception
-    rescue_from ActiveRecord::RecordNotFound,
-    :with => :render_not_found
-    rescue_from ActionController::RoutingError,
-    :with => :render_not_found
-    rescue_from ActionController::UnknownController,
-    :with => :render_not_found
-    rescue_from ::AbstractController::ActionNotFound,
-    :with => :render_not_found
+    rescue_from(ActiveRecord::RecordNotFound,
+                ActionController::RoutingError,
+                ActionController::UnknownController,
+                AbstractController::ActionNotFound,
+                with: :render_not_found)
+    rescue_from(Exception,
+                ActionController::UrlGenerationError,
+                with: :render_exception)
   end
 
   def unprocessable(message=nil)
@@ -35,33 +35,55 @@ class ApplicationController < ActionController::Base
     render_error status: 422
   end
 
-  def render_error(opts)
-    opts = {status: 500}.merge opts
+  def render_error(opts={})
+    opts[:status] ||= 500
     respond_to do |f|
       # json must come before html here, so it gets used as the
       # default format when js is requested by the client. This lets
       # ajax:error callback parse the response correctly, even though
       # the browser can't.
       f.json { render opts.merge(json: {success: false, errors: @errors}) }
-      f.html { render opts.merge(controller: 'application', action: 'error') }
+      f.html { render({action: 'error'}.merge(opts)) }
     end
   end
 
   def render_exception(e)
     logger.error e.inspect
     logger.error e.backtrace.collect { |x| x + "\n" }.join('') if e.backtrace
-    if @object.andand.errors.andand.full_messages.andand.any?
+    err_opts = {status: 422}
+    if e.is_a?(ArvadosApiClient::ApiError)
+      err_opts.merge!(action: 'api_error', locals: {api_error: e})
+      @errors = e.api_response[:errors]
+    elsif @object.andand.errors.andand.full_messages.andand.any?
       @errors = @object.errors.full_messages
     else
       @errors = [e.to_s]
     end
-    self.render_error status: 422
+    # If the user has an active session, and the API server is available,
+    # make user information available on the error page.
+    begin
+      load_api_token(session[:arvados_api_token])
+    rescue ArvadosApiClient::ApiError
+      load_api_token(nil)
+    end
+    # Preload projects trees for the template.  If that fails, set empty
+    # trees so error page rendering can proceed.  (It's easier to rescue the
+    # exception here than in a template.)
+    begin
+      build_project_trees
+    rescue ArvadosApiClient::ApiError
+      @my_project_tree ||= []
+      @shared_project_tree ||= []
+    end
+    render_error(err_opts)
   end
 
   def render_not_found(e=ActionController::RoutingError.new("Path not found"))
     logger.error e.inspect
     @errors = ["Path not found"]
-    self.render_error status: 404
+    set_thread_api_token do
+      self.render_error(action: '404', status: 404)
+    end
   end
 
   def find_objects_for_index
@@ -110,12 +132,15 @@ class ApplicationController < ActionController::Base
   end
 
   helper_method :next_page_offset
-  def next_page_offset
-    if @objects.respond_to?(:result_offset) and
-        @objects.respond_to?(:result_limit) and
-        @objects.respond_to?(:items_available)
-      next_offset = @objects.result_offset + @objects.result_limit
-      if next_offset < @objects.items_available
+  def next_page_offset objects=nil
+    if !objects
+      objects = @objects
+    end
+    if objects.respond_to?(:result_offset) and
+        objects.respond_to?(:result_limit) and
+        objects.respond_to?(:items_available)
+      next_offset = objects.result_offset + objects.result_limit
+      if next_offset < objects.items_available
         next_offset
       else
         nil
@@ -145,8 +170,17 @@ class ApplicationController < ActionController::Base
   end
 
   def choose
-    params[:limit] ||= 20
-    find_objects_for_index if !@objects
+    params[:limit] ||= 40
+    if !@objects
+      if params[:project_uuid] and !params[:project_uuid].empty?
+        # We want the chooser to show objects of the controllers's model_class
+        # type within a specific project specified by project_uuid, so fetch the
+        # project and request the contents of the project filtered on the
+        # controllers's model_class kind.
+        @objects = Group.find(params[:project_uuid]).contents({:filters => [['uuid', 'is_a', "arvados\##{ArvadosApiClient.class_kind(model_class)}"]]})
+      end
+      find_objects_for_index if !@objects
+    end
     respond_to do |f|
       if params[:partial]
         f.json {
@@ -227,7 +261,7 @@ class ApplicationController < ActionController::Base
       if @object.name and @object.name != ''
         @object.name = "Copy of #{@object.name}"
       else
-        @object.name = "Copy of unnamed #{@object.class_for_display.downcase}"
+        @object.name = ""
       end
     end
     @object.save!
@@ -249,22 +283,7 @@ class ApplicationController < ActionController::Base
   end
 
   def current_user
-    return Thread.current[:user] if Thread.current[:user]
-
-    if Thread.current[:arvados_api_token]
-      if session[:user]
-        if session[:user][:is_active] != true
-          Thread.current[:user] = User.current
-        else
-          Thread.current[:user] = User.new(session[:user])
-        end
-      else
-        Thread.current[:user] = User.current
-      end
-    else
-      logger.error "No API token in Thread"
-      return nil
-    end
+    Thread.current[:user]
   end
 
   def model_class
@@ -287,11 +306,15 @@ class ApplicationController < ActionController::Base
 
   protected
 
+  def strip_token_from_path(path)
+    path.sub(/([\?&;])api_token=[^&;]*[&;]?/, '\1')
+  end
+
   def redirect_to_login
     respond_to do |f|
       f.html {
         if request.method.in? ['GET', 'HEAD']
-          redirect_to arvados_api_client.arvados_login_url(return_to: request.url)
+          redirect_to arvados_api_client.arvados_login_url(return_to: strip_token_from_path(request.url))
         else
           flash[:error] = "Either you are not logged in, or your session has timed out. I can't automatically log you in and re-attempt this request."
           redirect_to :back
@@ -310,8 +333,7 @@ class ApplicationController < ActionController::Base
     [:arvados_api_token, :user].each do |key|
       start_values[key] = Thread.current[key]
     end
-    Thread.current[:arvados_api_token] = api_token
-    Thread.current[:user] = nil
+    load_api_token(api_token)
     begin
       yield
     ensure
@@ -323,130 +345,126 @@ class ApplicationController < ActionController::Base
     if params[:id] and params[:id].match /\D/
       params[:uuid] = params.delete :id
     end
-    if not model_class
-      @object = nil
-    elsif params[:uuid].is_a? String
-      if params[:uuid].empty?
+    begin
+      if not model_class
+        @object = nil
+      elsif not params[:uuid].is_a?(String)
+        @object = model_class.where(uuid: params[:uuid]).first
+      elsif params[:uuid].empty?
         @object = nil
+      elsif (model_class != Link and
+             resource_class_for_uuid(params[:uuid]) == Link)
+        @name_link = Link.find(params[:uuid])
+        @object = model_class.find(@name_link.head_uuid)
       else
-        if (model_class != Link and
-            resource_class_for_uuid(params[:uuid]) == Link)
-          @name_link = Link.find(params[:uuid])
-          @object = model_class.find(@name_link.head_uuid)
-        else
-          @object = model_class.find(params[:uuid])
-        end
+        @object = model_class.find(params[:uuid])
       end
-    else
-      @object = model_class.where(uuid: params[:uuid]).first
+    rescue ArvadosApiClient::NotFoundException, RuntimeError => error
+      if error.is_a?(RuntimeError) and (error.message !~ /^argument to find\(/)
+        raise
+      end
+      render_not_found(error)
+      return false
     end
   end
 
   def thread_clear
-    Thread.current[:arvados_api_token] = nil
-    Thread.current[:user] = nil
+    load_api_token(nil)
     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
     yield
     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
   end
 
-  def thread_with_api_token(login_optional = false)
+  # Set up the thread with the given API token and associated user object.
+  def load_api_token(new_token)
+    Thread.current[:arvados_api_token] = new_token
+    if new_token.nil?
+      Thread.current[:user] = nil
+    elsif (new_token == session[:arvados_api_token]) and
+        session[:user].andand[:is_active]
+      Thread.current[:user] = User.new(session[:user])
+    else
+      Thread.current[:user] = User.current
+    end
+  end
+
+  # If there's a valid api_token parameter, set up the session with that
+  # user's information.  Return true if the method redirects the request
+  # (usually a post-login redirect); false otherwise.
+  def setup_user_session
+    return false unless params[:api_token]
+    Thread.current[:arvados_api_token] = params[:api_token]
     begin
-      try_redirect_to_login = true
-      if params[:api_token]
-        try_redirect_to_login = false
-        Thread.current[:arvados_api_token] = params[:api_token]
-        # Before copying the token into session[], do a simple API
-        # call to verify its authenticity.
-        if verify_api_token
-          session[:arvados_api_token] = params[:api_token]
-          u = User.current
-          session[:user] = {
-            uuid: u.uuid,
-            email: u.email,
-            first_name: u.first_name,
-            last_name: u.last_name,
-            is_active: u.is_active,
-            is_admin: u.is_admin,
-            prefs: u.prefs
-          }
-          if !request.format.json? and request.method.in? ['GET', 'HEAD']
-            # Repeat this request with api_token in the (new) session
-            # cookie instead of the query string.  This prevents API
-            # tokens from appearing in (and being inadvisedly copied
-            # and pasted from) browser Location bars.
-            redirect_to request.fullpath.sub(%r{([&\?]api_token=)[^&\?]*}, '')
-          else
-            yield
-          end
-        else
-          @errors = ['Invalid API token']
-          self.render_error status: 401
-        end
-      elsif session[:arvados_api_token]
-        # In this case, the token must have already verified at some
-        # point, but it might have been revoked since.  We'll try
-        # using it, and catch the exception if it doesn't work.
-        try_redirect_to_login = false
-        Thread.current[:arvados_api_token] = session[:arvados_api_token]
-        begin
-          yield
-        rescue ArvadosApiClient::NotLoggedInException
-          try_redirect_to_login = true
-        end
+      user = User.current
+    rescue ArvadosApiClient::NotLoggedInException
+      false  # We may redirect to login, or not, based on the current action.
+    else
+      session[:arvados_api_token] = params[:api_token]
+      session[:user] = {
+        uuid: user.uuid,
+        email: user.email,
+        first_name: user.first_name,
+        last_name: user.last_name,
+        is_active: user.is_active,
+        is_admin: user.is_admin,
+        prefs: user.prefs
+      }
+      if !request.format.json? and request.method.in? ['GET', 'HEAD']
+        # Repeat this request with api_token in the (new) session
+        # cookie instead of the query string.  This prevents API
+        # tokens from appearing in (and being inadvisedly copied
+        # and pasted from) browser Location bars.
+        redirect_to strip_token_from_path(request.fullpath)
+        true
       else
-        logger.debug "No token received, session is #{session.inspect}"
-      end
-      if try_redirect_to_login
-        unless login_optional
-          redirect_to_login
-        else
-          # login is optional for this route so go on to the regular controller
-          Thread.current[:arvados_api_token] = nil
-          yield
-        end
+        false
       end
     ensure
-      # Remove token in case this Thread is used for anything else.
       Thread.current[:arvados_api_token] = nil
     end
   end
 
-  def thread_with_mandatory_api_token
-    thread_with_api_token(true) do
-      if Thread.current[:arvados_api_token]
-        yield
-      elsif session[:arvados_api_token]
-        # Expired session. Clear it before refreshing login so that,
-        # if this login procedure fails, we end up showing the "please
-        # log in" page instead of getting stuck in a redirect loop.
-        session.delete :arvados_api_token
-        redirect_to_login
-      else
-        render 'users/welcome'
-      end
+  # Save the session API token in thread-local storage, and yield.
+  # This method also takes care of session setup if the request
+  # provides a valid api_token parameter.
+  # If a token is unavailable or expired, the block is still run, with
+  # a nil token.
+  def set_thread_api_token
+    if Thread.current[:arvados_api_token]
+      yield   # An API token has already been found - pass it through.
+      return
+    elsif setup_user_session
+      return  # A new session was set up and received a response.
     end
-  end
 
-  # This runs after thread_with_mandatory_api_token in the filter chain.
-  def thread_with_optional_api_token
-    if Thread.current[:arvados_api_token]
-      # We are already inside thread_with_mandatory_api_token.
+    begin
+      load_api_token(session[:arvados_api_token])
       yield
-    else
-      # We skipped thread_with_mandatory_api_token. Use the optional version.
-      thread_with_api_token(true) do
+    rescue ArvadosApiClient::NotLoggedInException
+      # If we got this error with a token, it must've expired.
+      # Retry the request without a token.
+      unless Thread.current[:arvados_api_token].nil?
+        load_api_token(nil)
         yield
       end
+    ensure
+      # Remove token in case this Thread is used for anything else.
+      load_api_token(nil)
     end
   end
 
-  def verify_api_token
-    begin
-      Link.where(uuid: 'just-verifying-my-api-token')
-      true
-    rescue ArvadosApiClient::NotLoggedInException
-      false
+  # Reroute this request if an API token is unavailable.
+  def require_thread_api_token
+    if Thread.current[:arvados_api_token]
+      yield
+    elsif session[:arvados_api_token]
+      # Expired session. Clear it before refreshing login so that,
+      # if this login procedure fails, we end up showing the "please
+      # log in" page instead of getting stuck in a redirect loop.
+      session.delete :arvados_api_token
+      redirect_to_login
+    else
+      render 'users/welcome'
     end
   end
 
@@ -550,7 +568,8 @@ class ApplicationController < ActionController::Base
 
   helper_method :all_projects
   def all_projects
-    @all_projects ||= Group.filter([['group_class','in',['project','folder']]])
+    @all_projects ||= Group.
+      filter([['group_class','in',['project','folder']]]).order('name')
   end
 
   helper_method :my_projects
@@ -594,6 +613,57 @@ class ApplicationController < ActionController::Base
     end
   end
 
+  helper_method :my_project_tree
+  def my_project_tree
+    build_project_trees
+    @my_project_tree
+  end
+
+  helper_method :shared_project_tree
+  def shared_project_tree
+    build_project_trees
+    @shared_project_tree
+  end
+
+  def build_project_trees
+    return if @my_project_tree and @shared_project_tree
+    parent_of = {current_user.uuid => 'me'}
+    all_projects.each do |ob|
+      parent_of[ob.uuid] = ob.owner_uuid
+    end
+    children_of = {false => [], 'me' => [current_user]}
+    all_projects.each do |ob|
+      if ob.owner_uuid != current_user.uuid and
+          not parent_of.has_key? ob.owner_uuid
+        parent_of[ob.uuid] = false
+      end
+      children_of[parent_of[ob.uuid]] ||= []
+      children_of[parent_of[ob.uuid]] << ob
+    end
+    buildtree = lambda do |children_of, root_uuid=false|
+      tree = {}
+      children_of[root_uuid].andand.each do |ob|
+        tree[ob] = buildtree.call(children_of, ob.uuid)
+      end
+      tree
+    end
+    sorted_paths = lambda do |tree, depth=0|
+      paths = []
+      tree.keys.sort_by { |ob|
+        ob.is_a?(String) ? ob : ob.friendly_link_name
+      }.each do |ob|
+        paths << {object: ob, depth: depth}
+        paths += sorted_paths.call tree[ob], depth+1
+      end
+      paths
+    end
+    @my_project_tree =
+      sorted_paths.call buildtree.call(children_of, 'me')
+    @shared_project_tree =
+      sorted_paths.call({'Shared with me' =>
+                          buildtree.call(children_of, false)})
+  end
+
   helper_method :get_object
   def get_object uuid
     if @get_object.nil? and @objects
@@ -676,7 +746,7 @@ class ApplicationController < ActionController::Base
   def get_n_objects_of_class dataclass, size
     @objects_map_for ||= {}
 
-    raise ArgumentError, 'Argument is not a data class' unless dataclass.is_a? Class
+    raise ArgumentError, 'Argument is not a data class' unless dataclass.is_a? Class and dataclass < ArvadosBase
     raise ArgumentError, 'Argument is not a valid limit size' unless (size && size>0)
 
     # if the objects_map_for has a value for this dataclass, and the