Merge branch 'master' into 3140-project-content-tabs
[arvados.git] / services / api / app / controllers / application_controller.rb
index 663baa8fa762dc641c1a1bae4d1506ac88153b23..8464a4ab5e05a515a557c5bdd9407724374642ef 100644 (file)
@@ -1,3 +1,16 @@
+module ApiTemplateOverride
+  def allowed_to_render?(fieldset, field, model, options)
+    if options[:select]
+      return options[:select].include? field.to_s
+    end
+    super
+  end
+end
+
+class ActsAsApi::ApiTemplate
+  prepend ApiTemplateOverride
+end
+
 require 'load_param'
 require 'record_filters'
 
@@ -10,27 +23,41 @@ class ApplicationController < ActionController::Base
   respond_to :json
   protect_from_forgery
 
+  ERROR_ACTIONS = [:render_error, :render_not_found]
+
+  before_filter :respond_with_json_by_default
   before_filter :remote_ip
-  before_filter :require_auth_scope_all, :except => :render_not_found
-  before_filter :catch_redirect_hint
+  before_filter :load_read_auths
+  before_filter :require_auth_scope, except: ERROR_ACTIONS
 
-  before_filter :load_where_param, :only => :index
-  before_filter :load_filters_param, :only => :index
+  before_filter :catch_redirect_hint
+  before_filter(:find_object_by_uuid,
+                except: [:index, :create] + ERROR_ACTIONS)
+  before_filter :load_limit_offset_order_params, only: [:index, :contents]
+  before_filter :load_where_param, only: [:index, :contents]
+  before_filter :load_filters_param, only: [:index, :contents]
   before_filter :find_objects_for_index, :only => :index
-  before_filter :find_object_by_uuid, :except => [:index, :create,
-                                                  :render_error,
-                                                  :render_not_found]
   before_filter :reload_object_before_update, :only => :update
-  before_filter :render_404_if_no_object, except: [:index, :create,
-                                                   :render_error,
-                                                   :render_not_found]
+  before_filter(:render_404_if_no_object,
+                except: [:index, :create] + ERROR_ACTIONS)
 
   theme :select_theme
 
   attr_accessor :resource_attrs
 
+  begin
+    rescue_from(Exception,
+                ArvadosModel::PermissionDeniedError,
+                :with => :render_error)
+    rescue_from(ActiveRecord::RecordNotFound,
+                ActionController::RoutingError,
+                ActionController::UnknownController,
+                AbstractController::ActionNotFound,
+                :with => :render_not_found)
+  end
+
   def index
-    @objects.uniq!(&:id)
+    @objects.uniq!(&:id) if @select.nil? or @select.include? "id"
     if params[:eager] and params[:eager] != '0' and params[:eager] != 0 and params[:eager] != ''
       @objects.each(&:eager_load_associations)
     end
@@ -68,21 +95,6 @@ class ApplicationController < ActionController::Base
     end
   end
 
-  begin
-    rescue_from Exception,
-    :with => :render_error
-    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 ArvadosModel::PermissionDeniedError,
-    :with => :render_error
-  end
-
   def render_404_if_no_object
     render_not_found "Object not found" if !@object
   end
@@ -98,22 +110,45 @@ class ApplicationController < ActionController::Base
       errors = [e.inspect]
     end
     status = e.respond_to?(:http_status) ? e.http_status : 422
-    render json: { errors: errors }, status: status
+    send_error(*errors, status: status)
   end
 
   def render_not_found(e=ActionController::RoutingError.new("Path not found"))
     logger.error e.inspect
-    render json: { errors: ["Path not found"] }, status: 404
+    send_error("Path not found", status: 404)
   end
 
   protected
 
-  def apply_where_limit_order_params
-    ft = record_filters @filters
+  def send_error(*args)
+    if args.last.is_a? Hash
+      err = args.pop
+    else
+      err = {}
+    end
+    err[:errors] ||= args
+    err[:error_token] = [Time.now.utc.to_i, "%08x" % rand(16 ** 8)].join("+")
+    status = err.delete(:status) || 422
+    logger.error "Error #{err[:error_token]}: #{status}"
+    render json: err, status: status
+  end
+
+  def find_objects_for_index
+    @objects ||= model_class.readable_by(*@read_users)
+    apply_where_limit_order_params
+  end
+
+  def apply_filters
+    ft = record_filters @filters, @objects.table_name
     if ft[:cond_out].any?
       @objects = @objects.where(ft[:cond_out].join(' AND '), *ft[:param_out])
     end
+  end
+
+  def apply_where_limit_order_params
+    apply_filters
 
+    ar_table_name = @objects.table_name
     if @where.is_a? Hash and @where.any?
       conditions = ['1=1']
       @where.each do |attr,value|
@@ -123,7 +158,10 @@ class ApplicationController < ActionController::Base
               value[0] == 'contains' then
             ilikes = []
             model_class.searchable_columns('ilike').each do |column|
-              ilikes << "#{table_name}.#{column} ilike ?"
+              # Including owner_uuid in an "any column" search will
+              # probably just return a lot of false positives.
+              next if column == 'owner_uuid'
+              ilikes << "#{ar_table_name}.#{column} ilike ?"
               conditions << "%#{value[1]}%"
             end
             if ilikes.any?
@@ -133,24 +171,24 @@ class ApplicationController < ActionController::Base
         elsif attr.to_s.match(/^[a-z][_a-z0-9]+$/) and
             model_class.columns.collect(&:name).index(attr.to_s)
           if value.nil?
-            conditions[0] << " and #{table_name}.#{attr} is ?"
+            conditions[0] << " and #{ar_table_name}.#{attr} is ?"
             conditions << nil
           elsif value.is_a? Array
             if value[0] == 'contains' and value.length == 2
-              conditions[0] << " and #{table_name}.#{attr} like ?"
+              conditions[0] << " and #{ar_table_name}.#{attr} like ?"
               conditions << "%#{value[1]}%"
             else
-              conditions[0] << " and #{table_name}.#{attr} in (?)"
+              conditions[0] << " and #{ar_table_name}.#{attr} in (?)"
               conditions << value
             end
           elsif value.is_a? String or value.is_a? Fixnum or value == true or value == false
-            conditions[0] << " and #{table_name}.#{attr}=?"
+            conditions[0] << " and #{ar_table_name}.#{attr}=?"
             conditions << value
           elsif value.is_a? Hash
             # Not quite the same thing as "equal?" but better than nothing?
             value.each do |k,v|
               if v.is_a? String
-                conditions[0] << " and #{table_name}.#{attr} ilike ?"
+                conditions[0] << " and #{ar_table_name}.#{attr} ilike ?"
                 conditions << "%#{k}%#{v}%"
               end
             end
@@ -164,51 +202,11 @@ class ApplicationController < ActionController::Base
       end
     end
 
-    if params[:limit]
-      begin
-        @limit = params[:limit].to_i
-      rescue
-        raise ArgumentError.new("Invalid value for limit parameter")
-      end
-    else
-      @limit = 100
-    end
+    @objects = @objects.select(@select.map { |s| "#{table_name}.#{ActiveRecord::Base.connection.quote_column_name s.to_s}" }.join ", ") if @select
+    @objects = @objects.order(@orders.join ", ") if @orders.any?
     @objects = @objects.limit(@limit)
-
-    orders = []
-
-    if params[:offset]
-      begin
-        @objects = @objects.offset(params[:offset].to_i)
-        @offset = params[:offset].to_i
-      rescue
-        raise ArgumentError.new("Invalid value for limit parameter")
-      end
-    else
-      @offset = 0
-    end
-
-    orders = []
-    if params[:order]
-      params[:order].split(',').each do |order|
-        attr, direction = order.strip.split " "
-        direction ||= 'asc'
-        if attr.match /^[a-z][_a-z0-9]+$/ and
-            model_class.columns.collect(&:name).index(attr) and
-            ['asc','desc'].index direction.downcase
-          orders << "#{table_name}.#{attr} #{direction.downcase}"
-        end
-      end
-    end
-    if orders.empty?
-      orders << "#{table_name}.modified_at desc"
-    end
-    @objects = @objects.order(orders.join ", ")
-  end
-
-  def find_objects_for_index
-    @objects ||= model_class.readable_by(current_user)
-    apply_where_limit_order_params
+    @objects = @objects.offset(@offset)
+    @objects = @objects.uniq(@distinct) if not @distinct.nil?
   end
 
   def resource_attrs
@@ -233,17 +231,32 @@ class ApplicationController < ActionController::Base
   end
 
   # Authentication
+  def load_read_auths
+    @read_auths = []
+    if current_api_client_authorization
+      @read_auths << current_api_client_authorization
+    end
+    # Load reader tokens if this is a read request.
+    # If there are too many reader tokens, assume the request is malicious
+    # and ignore it.
+    if request.get? and params[:reader_tokens] and
+        params[:reader_tokens].size < 100
+      @read_auths += ApiClientAuthorization
+        .includes(:user)
+        .where('api_token IN (?) AND
+                (expires_at IS NULL OR expires_at > CURRENT_TIMESTAMP)',
+               params[:reader_tokens])
+        .all
+    end
+    @read_auths.select! { |auth| auth.scopes_allow_request? request }
+    @read_users = @read_auths.map { |auth| auth.user }.uniq
+  end
+
   def require_login
-    if current_user
-      true
-    else
+    if not current_user
       respond_to do |format|
-        format.json {
-          render :json => { errors: ['Not logged in'] }.to_json, status: 401
-        }
-        format.html  {
-          redirect_to '/auth/joshid'
-        }
+        format.json { send_error("Not logged in", status: 401) }
+        format.html { redirect_to '/auth/joshid' }
       end
       false
     end
@@ -251,17 +264,23 @@ class ApplicationController < ActionController::Base
 
   def admin_required
     unless current_user and current_user.is_admin
-      render :json => { errors: ['Forbidden'] }.to_json, status: 403
+      send_error("Forbidden", status: 403)
     end
   end
 
-  def require_auth_scope_all
-    require_login and require_auth_scope(['all'])
+  def require_auth_scope
+    if @read_auths.empty?
+      if require_login != false
+        send_error("Forbidden", status: 403)
+      end
+      false
+    end
   end
 
-  def require_auth_scope(ok_scopes)
-    unless current_api_client_auth_has_scope(ok_scopes)
-      render :json => { errors: ['Forbidden'] }.to_json, status: 403
+  def respond_with_json_by_default
+    html_index = request.accepts.index(Mime::HTML)
+    if html_index.nil? or request.accepts[0...html_index].include?(Mime::JSON)
+      request.format = :json
     end
   end
 
@@ -282,6 +301,11 @@ class ApplicationController < ActionController::Base
       params[:uuid] = params.delete :id
     end
     @where = { uuid: params[:uuid] }
+    @offset = 0
+    @limit = 1
+    @orders = []
+    @filters = []
+    @objects = nil
     find_objects_for_index
     @object = @objects.first
   end
@@ -294,36 +318,54 @@ class ApplicationController < ActionController::Base
     end
   end
 
-  def self.accept_attribute_as_json(attr, force_class=nil)
-    before_filter lambda { accept_attribute_as_json attr, force_class }
+  def load_json_value(hash, key, must_be_class=nil)
+    if hash[key].is_a? String
+      hash[key] = Oj.load(hash[key], symbol_keys: false)
+      if must_be_class and !hash[key].is_a? must_be_class
+        raise TypeError.new("parameter #{key.to_s} must be a #{must_be_class.to_s}")
+      end
+    end
+  end
+
+  def self.accept_attribute_as_json(attr, must_be_class=nil)
+    before_filter lambda { accept_attribute_as_json attr, must_be_class }
   end
   accept_attribute_as_json :properties, Hash
   accept_attribute_as_json :info, Hash
-  def accept_attribute_as_json(attr, force_class)
+  def accept_attribute_as_json(attr, must_be_class)
     if params[resource_name] and resource_attrs.is_a? Hash
-      if resource_attrs[attr].is_a? String
-        resource_attrs[attr] = Oj.load(resource_attrs[attr],
-                                       symbol_keys: false)
-        if force_class and !resource_attrs[attr].is_a? force_class
-          raise TypeError.new("#{resource_name}[#{attr.to_s}] must be a #{force_class.to_s}")
-        end
-      elsif resource_attrs[attr].is_a? Hash
+      if resource_attrs[attr].is_a? Hash
         # Convert symbol keys to strings (in hashes provided by
         # resource_attrs)
         resource_attrs[attr] = resource_attrs[attr].
           with_indifferent_access.to_hash
+      else
+        load_json_value(resource_attrs, attr, must_be_class)
       end
     end
   end
 
+  def self.accept_param_as_json(key, must_be_class=nil)
+    prepend_before_filter lambda { load_json_value(params, key, must_be_class) }
+  end
+  accept_param_as_json :reader_tokens, Array
+
   def render_list
+    if @select
+      # This information helps clients understand what they're seeing
+      # (Workbench always expects it), but they can't select it explicitly
+      # because it's not an SQL column.  Always add it.
+      # I believe this is safe because clients can always deduce what they're
+      # looking at by the returned UUID anyway.
+      @select |= ["kind"]
+    end
     @object_list = {
       :kind  => "arvados##{(@response_resource_name || resource_name).camelize(:lower)}List",
       :etag => "",
       :self_link => "",
       :offset => @offset,
       :limit => @limit,
-      :items => @objects.as_api_response(nil)
+      :items => @objects.as_api_response(nil, {select: @select})
     }
     if @objects.respond_to? :except
       @object_list[:items_available] = @objects.
@@ -348,7 +390,11 @@ class ApplicationController < ActionController::Base
     {
       filters: { type: 'array', required: false },
       where: { type: 'object', required: false },
-      order: { type: 'string', required: false }
+      order: { type: 'array', required: false },
+      select: { type: 'array', required: false },
+      distinct: { type: 'boolean', required: false },
+      limit: { type: 'integer', required: false, default: DEFAULT_LIMIT },
+      offset: { type: 'integer', required: false, default: 0 },
     }
   end