X-Git-Url: https://git.arvados.org/arvados.git/blobdiff_plain/a5e1609e6f34ee041a92fa089ccf0ed66448bf15..a013ad4808a9888e5632b1965fc13c8b1dcc045d:/apps/workbench/app/controllers/application_controller.rb diff --git a/apps/workbench/app/controllers/application_controller.rb b/apps/workbench/app/controllers/application_controller.rb index 02b9501e93..4f5d8fdcd2 100644 --- a/apps/workbench/app/controllers/application_controller.rb +++ b/apps/workbench/app/controllers/application_controller.rb @@ -8,24 +8,34 @@ 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 :set_cache_buster + before_filter :accept_uuid_as_id_param, except: ERROR_ACTIONS before_filter :check_user_agreements, except: ERROR_ACTIONS + before_filter :check_user_profile, except: ERROR_ACTIONS before_filter :check_user_notifications, except: ERROR_ACTIONS - before_filter :find_object_by_uuid, except: [:index, :choose] + ERROR_ACTIONS + before_filter :load_filters_and_paging_params, except: ERROR_ACTIONS + before_filter :find_object_by_uuid, except: [:create, :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 set_cache_buster + response.headers["Cache-Control"] = "no-cache, no-store, max-age=0, must-revalidate" + response.headers["Pragma"] = "no-cache" + response.headers["Expires"] = "Fri, 01 Jan 1990 00:00:00 GMT" end def unprocessable(message=nil) @@ -35,36 +45,70 @@ 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 + # Make user information available on the error page, falling back to the + # session cache if the API server is unavailable. + begin + load_api_token(session[:arvados_api_token]) + rescue ArvadosApiClient::ApiError + unless session[:user].nil? + begin + Thread.current[:user] = User.new(session[:user]) + rescue ArvadosApiClient::ApiError + # This can happen if User's columns are unavailable. Nothing to do. + end + end + end + # Preload projects trees for the template. If that's not doable, set empty + # trees so error page rendering can proceed. (It's easier to rescue the + # exception here than in a template.) + unless current_user.nil? + begin + build_project_trees + rescue ArvadosApiClient::ApiError + # Fall back to the default-setting code later. + end + end + @my_project_tree ||= [] + @shared_project_tree ||= [] + 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 + def load_filters_and_paging_params + @order = params[:order] || 'created_at desc' + @order = [@order] unless @order.is_a? Array + @limit ||= 200 if params[:limit] @limit = params[:limit].to_i @@ -80,10 +124,24 @@ class ApplicationController < ActionController::Base filters = params[:filters] if filters.is_a? String filters = Oj.load filters + elsif filters.is_a? Array + filters = filters.collect do |filter| + if filter.is_a? String + # Accept filters[]=["foo","=","bar"] + Oj.load filter + else + # Accept filters=[["foo","=","bar"]] + filter + end + end end + # After this, params[:filters] can be trusted to be an array of arrays: + params[:filters] = filters @filters += filters end + end + def find_objects_for_index @objects ||= model_class @objects = @objects.filter(@filters).limit(@limit).offset(@offset) end @@ -92,10 +150,8 @@ class ApplicationController < ActionController::Base respond_to do |f| f.json { render json: @objects } f.html { - if params['tab_pane'] - comparable = self.respond_to? :compare - render(partial: 'show_' + params['tab_pane'].downcase, - locals: { comparable: comparable, objects: @objects }) + if params[:tab_pane] + render_pane params[:tab_pane] else render end @@ -104,18 +160,38 @@ class ApplicationController < ActionController::Base end end + helper_method :render_pane + def render_pane tab_pane, opts={} + render_opts = { + partial: 'show_' + tab_pane.downcase, + locals: { + comparable: self.respond_to?(:compare), + objects: @objects, + tab_pane: tab_pane + }.merge(opts[:locals] || {}) + } + if opts[:to_string] + render_to_string render_opts + else + render render_opts + end + end + def index find_objects_for_index if !@objects render_index 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 @@ -123,17 +199,28 @@ class ApplicationController < ActionController::Base end end + helper_method :next_page_href + def next_page_href with_params={} + if next_page_offset + url_for with_params.merge(offset: next_page_offset) + end + end + def show if !@object return render_not_found("object not found") end respond_to do |f| - f.json { render json: @object.attributes.merge(href: url_for(@object)) } + f.json do + extra_attrs = { href: url_for(action: :show, id: @object) } + @object.textile_attributes.each do |textile_attr| + extra_attrs.merge!({ "#{textile_attr}Textile" => view_context.render_markup(@object.attributes[textile_attr]) }) + end + render json: @object.attributes.merge(extra_attrs) + end f.html { if params['tab_pane'] - comparable = self.respond_to? :compare - render(partial: 'show_' + params['tab_pane'].downcase, - locals: { comparable: comparable, objects: @objects }) + render_pane(if params['tab_pane'].is_a? Hash then params['tab_pane']["name"] else params['tab_pane'] end) elsif request.method.in? ['GET', 'HEAD'] render else @@ -145,22 +232,20 @@ class ApplicationController < ActionController::Base end def choose - params[:limit] ||= 20 - find_objects_for_index if !@objects + params[:limit] ||= 40 respond_to do |f| if params[:partial] f.json { + find_objects_for_index if !@objects render json: { content: render_to_string(partial: "choose_rows.html", - formats: [:html], - locals: { - multiple: params[:multiple] - }), - next_page_href: @next_page_href + formats: [:html]), + next_page_href: next_page_href(partial: params[:partial]) } } end f.js { + find_objects_for_index if !@objects render partial: 'choose', locals: {multiple: params[:multiple]} } end @@ -205,7 +290,7 @@ class ApplicationController < ActionController::Base @object ||= model_class.new @new_resource_attrs, params["options"] if @object.save respond_to do |f| - f.json { render json: @object.attributes.merge(href: url_for(@object)) } + f.json { render json: @object.attributes.merge(href: url_for(action: :show, id: @object)) } f.html { redirect_to @object } @@ -227,7 +312,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 +334,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 +357,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 +384,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 @@ -319,134 +392,136 @@ class ApplicationController < ActionController::Base end end - def find_object_by_uuid + + def accept_uuid_as_id_param 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? + end + + def find_object_by_uuid + 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 + 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] + # If we later have trouble contacting the API server, we still want + # to be able to render basic user information in the UI--see + # render_exception above. We store that in the session here. This is + # not intended to be used as a general-purpose cache. See #2891. + 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 + # Redirect to login/welcome if client provided expired API token (or none at all) + 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 + redirect_to welcome_users_path(return_to: request.fullpath) end end @@ -457,19 +532,22 @@ class ApplicationController < ActionController::Base end end + helper_method :unsigned_user_agreements + def unsigned_user_agreements + @signed_ua_uuids ||= UserAgreement.signatures.map &:head_uuid + @unsigned_user_agreements ||= UserAgreement.all.map do |ua| + if not @signed_ua_uuids.index ua.uuid + Collection.find(ua.uuid) + end + end.compact + end + def check_user_agreements if current_user && !current_user.is_active if not current_user.is_invited - return render 'users/inactive' + return redirect_to inactive_users_path(return_to: request.fullpath) end - signatures = UserAgreement.signatures - @signed_ua_uuids = UserAgreement.signatures.map &:head_uuid - @required_user_agreements = UserAgreement.all.map do |ua| - if not @signed_ua_uuids.index ua.uuid - Collection.find(ua.uuid) - end - end.compact - if @required_user_agreements.empty? + if unsigned_user_agreements.empty? # No agreements to sign. Perhaps we just need to ask? current_user.activate if !current_user.is_active @@ -478,12 +556,47 @@ class ApplicationController < ActionController::Base end end if !current_user.is_active - render 'user_agreements/index' + redirect_to user_agreements_path(return_to: request.fullpath) end end true end + def check_user_profile + if request.method.downcase != 'get' || params[:partial] || + params[:tab_pane] || params[:action_method] || + params[:action] == 'setup_popup' + return true + end + + if missing_required_profile? + redirect_to profile_user_path(current_user.uuid, return_to: request.fullpath) + end + true + end + + helper_method :missing_required_profile? + def missing_required_profile? + missing_required = false + + profile_config = Rails.configuration.user_profile_form_fields + if current_user && profile_config + current_user_profile = current_user.prefs[:profile] + profile_config.kind_of?(Array) && profile_config.andand.each do |entry| + if entry['required'] + if !current_user_profile || + !current_user_profile[entry['key'].to_sym] || + current_user_profile[entry['key'].to_sym].empty? + missing_required = true + break + end + end + end + end + + missing_required + end + def select_theme return Rails.configuration.arvados_theme end @@ -499,15 +612,6 @@ class ApplicationController < ActionController::Base } } - #@@notification_tests.push lambda { |controller, current_user| - # Job.limit(1).where(created_by: current_user.uuid).each do - # return nil - # end - # return lambda { |view| - # view.render partial: 'notifications/jobs_notification' - # } - #} - @@notification_tests.push lambda { |controller, current_user| Collection.limit(1).where(created_by: current_user.uuid).each do return nil @@ -532,7 +636,7 @@ class ApplicationController < ActionController::Base @notification_count = 0 @notifications = [] - if current_user + if current_user.andand.is_active @showallalerts = false @@notification_tests.each do |t| a = t.call(self, current_user) @@ -551,7 +655,7 @@ class ApplicationController < ActionController::Base helper_method :all_projects def all_projects @all_projects ||= Group. - filter([['group_class','in',['project','folder']]]).order('name') + filter([['group_class','=','project']]).order('name') end helper_method :my_projects @@ -591,8 +695,52 @@ class ApplicationController < ActionController::Base (Job.limit(10) | PipelineInstance.limit(10)). sort_by do |x| - x.finished_at || x.started_at || x.created_at rescue x.created_at + (x.finished_at || x.started_at rescue nil) || x.modified_at || x.created_at + end.reverse + end + + helper_method :running_pipelines + def running_pipelines + pi = PipelineInstance.order(["started_at asc", "created_at asc"]).filter([["state", "in", ["RunningOnServer", "RunningOnClient"]]]) + jobs = {} + pi.each do |pl| + pl.components.each do |k,v| + if v.is_a? Hash and v[:job] + jobs[v[:job][:uuid]] = {} + end + end end + + if jobs.keys.any? + Job.filter([["uuid", "in", jobs.keys]]).each do |j| + jobs[j[:uuid]] = j + end + + pi.each do |pl| + pl.components.each do |k,v| + if v.is_a? Hash and v[:job] + v[:job] = jobs[v[:job][:uuid]] + end + end + end + end + + pi + end + + helper_method :finished_pipelines + def finished_pipelines lim + PipelineInstance.limit(lim).order(["finished_at desc"]).filter([["state", "in", ["Complete", "Failed", "Paused"]], ["finished_at", "!=", nil]]) + end + + helper_method :recent_collections + def recent_collections lim + c = Collection.limit(lim).order(["modified_at desc"]).filter([["owner_uuid", "is_a", "arvados#group"]]) + own = {} + Group.filter([["uuid", "in", c.map(&:owner_uuid)]]).each do |g| + own[g[:uuid]] = g + end + {collections: c, owners: own} end helper_method :my_project_tree @@ -642,7 +790,7 @@ class ApplicationController < ActionController::Base @my_project_tree = sorted_paths.call buildtree.call(children_of, 'me') @shared_project_tree = - sorted_paths.call({'Shared with me' => + sorted_paths.call({'Projects shared with me' => buildtree.call(children_of, false)}) end @@ -662,7 +810,7 @@ class ApplicationController < ActionController::Base crumbs = [] current = @name_link || @object while current - if current.is_a?(Group) and current.group_class.in?(['project','folder']) + if current.is_a?(Group) and current.group_class == 'project' crumbs.prepend current end if current.is_a? Link @@ -676,7 +824,7 @@ class ApplicationController < ActionController::Base helper_method :current_project_uuid def current_project_uuid - if @object.is_a? Group and @object.group_class.in?(['project','folder']) + if @object.is_a? Group and @object.group_class == 'project' @object.uuid elsif @name_link.andand.tail_uuid @name_link.tail_uuid @@ -728,7 +876,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 @@ -854,4 +1002,7 @@ class ApplicationController < ActionController::Base @objects_for end + def wiselinks_layout + 'body' + end end