Merge branch '8784-dir-listings'
[arvados.git] / apps / workbench / app / controllers / collections_controller.rb
index baa7661aac00bf2149a4a70478a7d9c70d4bad00..f8fcf5108f025659bf5058f2861ef42d2e1b5781 100644 (file)
@@ -1,4 +1,18 @@
+# Copyright (C) The Arvados Authors. All rights reserved.
+#
+# SPDX-License-Identifier: AGPL-3.0
+
+require "arvados/keep"
+require "arvados/collection"
+require "uri"
+
 class CollectionsController < ApplicationController
+  include ActionController::Live
+
+  skip_around_filter :require_thread_api_token, if: proc { |ctrl|
+    Rails.configuration.anonymous_user_token and
+    'show' == ctrl.action_name
+  }
   skip_around_filter(:require_thread_api_token,
                      only: [:show_file, :show_file_links])
   skip_before_filter(:find_object_by_uuid,
@@ -10,7 +24,9 @@ class CollectionsController < ApplicationController
   RELATION_LIMIT = 5
 
   def show_pane_list
-    %w(Files Provenance_graph Used_by Advanced)
+    panes = %w(Files Upload Tags Provenance_graph Used_by Advanced)
+    panes = panes - %w(Upload) unless (@object.editable? rescue false)
+    panes
   end
 
   def set_persistent
@@ -43,34 +59,6 @@ class CollectionsController < ApplicationController
     end
   end
 
-  def choose
-    # Find collections using default find_objects logic, then search for name
-    # links, and preload any other links connected to the collections that are
-    # found.
-    # Name links will be obsolete when issue #3036 is merged,
-    # at which point this entire custom #choose function can probably be
-    # eliminated.
-
-    params[:limit] ||= 40
-
-    find_objects_for_index
-    @collections = @objects
-
-    @filters += [['link_class','=','name'],
-                 ['head_uuid','is_a','arvados#collection']]
-
-    @objects = Link
-    find_objects_for_index
-
-    @name_links = @objects
-
-    @objects = Collection.
-      filter([['uuid','in',@name_links.collect(&:head_uuid)]])
-
-    preload_links_for_objects (@collections.to_a + @objects.to_a)
-    super
-  end
-
   def index
     # API server index doesn't return manifest_text by default, but our
     # callers want it unless otherwise specified.
@@ -78,7 +66,7 @@ class CollectionsController < ApplicationController
     base_search = Collection.select(@select)
     if params[:search].andand.length.andand > 0
       tags = Link.where(any: ['contains', params[:search]])
-      @collections = (base_search.where(uuid: tags.collect(&:head_uuid)) |
+      @objects = (base_search.where(uuid: tags.collect(&:head_uuid)) |
                       base_search.where(any: ['contains', params[:search]])).
         uniq { |c| c.uuid }
     else
@@ -94,12 +82,11 @@ class CollectionsController < ApplicationController
         offset = 0
       end
 
-      @collections = base_search.limit(limit).offset(offset)
+      @objects = base_search.limit(limit).offset(offset)
     end
-    @links = Link.limit(1000).
-      where(head_uuid: @collections.collect(&:uuid))
+    @links = Link.where(head_uuid: @objects.collect(&:uuid))
     @collection_info = {}
-    @collections.each do |c|
+    @objects.each do |c|
       @collection_info[c.uuid] = {
         tag_links: [],
         wanted: false,
@@ -128,6 +115,10 @@ class CollectionsController < ApplicationController
   end
 
   def show_file_links
+    if Rails.configuration.keep_web_url || Rails.configuration.keep_web_download_url
+      # show_file will redirect to keep-web's directory listing
+      return show_file
+    end
     Thread.current[:reader_tokens] = [params[:reader_token]]
     return if false.equal?(find_object_by_uuid)
     render layout: false
@@ -138,24 +129,60 @@ class CollectionsController < ApplicationController
     # we ask the API server if the file actually exists.  This serves two
     # purposes: it lets us return a useful status code for common errors, and
     # helps us figure out which token to provide to arv-get.
+    # The order of searched tokens is important: because the anonymous user
+    # token is passed along with every API request, we have to check it first.
+    # Otherwise, it's impossible to know whether any other request succeeded
+    # because of the reader token.
     coll = nil
-    tokens = [Thread.current[:arvados_api_token], params[:reader_token]].compact
+    tokens = [(Rails.configuration.anonymous_user_token || nil),
+              params[:reader_token],
+              Thread.current[:arvados_api_token]].compact
     usable_token = find_usable_token(tokens) do
       coll = Collection.find(params[:uuid])
     end
     if usable_token.nil?
-      return  # Response already rendered.
-    elsif params[:file].nil? or not coll.manifest.has_file?(params[:file])
+      # Response already rendered.
+      return
+    end
+
+    # If we are configured to use a keep-web server, just redirect to
+    # the appropriate URL.
+    if Rails.configuration.keep_web_url or
+        Rails.configuration.keep_web_download_url
+      opts = {}
+      if usable_token == params[:reader_token]
+        opts[:path_token] = usable_token
+      elsif usable_token == Rails.configuration.anonymous_user_token
+        # Don't pass a token at all
+      else
+        # We pass the current user's real token only if it's necessary
+        # to read the collection.
+        opts[:query_token] = usable_token
+      end
+      opts[:disposition] = params[:disposition] if params[:disposition]
+      return redirect_to keep_web_url(params[:uuid], params[:file], opts)
+    end
+
+    # No keep-web server available. Get the file data with arv-get,
+    # and serve it through Rails.
+
+    file_name = params[:file].andand.sub(/^(\.\/|\/|)/, './')
+    if file_name.nil? or not coll.manifest.has_file?(file_name)
       return render_not_found
     end
 
     opts = params.merge(arvados_api_token: usable_token)
+
+    # Handle Range requests. Currently we support only 'bytes=0-....'
     if request.headers.include? 'HTTP_RANGE'
-      # Currently only 'bytes=0-....' is supported.
       if m = /^bytes=0-(\d+)/.match(request.headers['HTTP_RANGE'])
         opts[:maxbytes] = m[1]
+        size = params[:size] || '*'
+        self.response.status = 206
+        self.response.headers['Content-Range'] = "bytes 0-#{m[1]}/#{size}"
       end
     end
+
     ext = File.extname(params[:file])
     self.response.headers['Content-Type'] =
       Rack::Mime::MIME_TYPES[ext] || 'application/octet-stream'
@@ -167,7 +194,13 @@ class CollectionsController < ApplicationController
       self.response.headers['Content-Length'] = size.to_s
     end
     self.response.headers['Content-Disposition'] = params[:disposition] if params[:disposition]
-    self.response_body = file_enumerator opts
+    begin
+      file_enumerator(opts).each do |bytes|
+        response.stream.write bytes
+      end
+    ensure
+      response.stream.close
+    end
   end
 
   def sharing_scopes
@@ -182,82 +215,171 @@ class CollectionsController < ApplicationController
     end
   end
 
+  def find_object_by_uuid
+    if not Keep::Locator.parse params[:id]
+      super
+    end
+  end
+
   def show
     return super if !@object
-    if current_user
-      jobs_with = lambda do |conds|
-        Job.limit(RELATION_LIMIT).where(conds)
-          .results.sort_by { |j| j.finished_at || j.created_at }
-      end
-      @output_of = jobs_with.call(output: @object.portable_data_hash)
-      @log_of = jobs_with.call(log: @object.portable_data_hash)
-      @project_links = Link.limit(RELATION_LIMIT).order("modified_at DESC")
-        .where(head_uuid: @object.uuid, link_class: 'name').results
-      project_hash = Group.where(uuid: @project_links.map(&:tail_uuid)).to_hash
-      @projects = project_hash.values
-
-      if @object.uuid.match /[0-9a-f]{32}/
-        @same_pdh = Collection.filter([["portable_data_hash", "=", @object.portable_data_hash]])
-        owners = @same_pdh.map {|s| s.owner_uuid}.to_a
-        preload_objects_for_dataclass Group, owners
-        preload_objects_for_dataclass User, owners
-      end
 
-      @permissions = Link.limit(RELATION_LIMIT).order("modified_at DESC")
-        .where(head_uuid: @object.uuid, link_class: 'permission',
-               name: 'can_read').results
-      @logs = Log.limit(RELATION_LIMIT).order("created_at DESC")
-        .where(object_uuid: @object.uuid).results
-      @is_persistent = Link.limit(1)
-        .where(head_uuid: @object.uuid, tail_uuid: current_user.uuid,
-               link_class: 'resources', name: 'wants')
-        .results.any?
-      @search_sharing = search_scopes
-    end
+    @logs = []
 
     if params["tab_pane"] == "Provenance_graph"
       @prov_svg = ProvenanceHelper::create_provenance_graph(@object.provenance, "provenance_svg",
                                                             {:request => request,
-                                                              :direction => :bottom_up,
-                                                              :combine_jobs => :script_only}) rescue nil
+                                                             :direction => :top_down,
+                                                             :combine_jobs => :script_only}) rescue nil
     end
-    if params["tab_pane"] == "Used_by"
-      @used_by_svg = ProvenanceHelper::create_provenance_graph(@object.used_by, "used_by_svg",
-                                                               {:request => request,
-                                                                 :direction => :top_down,
-                                                                 :combine_jobs => :script_only,
-                                                                 :pdata_only => true}) rescue nil
+
+    if current_user
+      if Keep::Locator.parse params["uuid"]
+        @same_pdh = Collection.filter([["portable_data_hash", "=", @object.portable_data_hash]]).limit(20)
+        if @same_pdh.results.size == 1
+          redirect_to collection_path(@same_pdh[0]["uuid"])
+          return
+        end
+        owners = @same_pdh.map(&:owner_uuid).to_a.uniq
+        preload_objects_for_dataclass Group, owners
+        preload_objects_for_dataclass User, owners
+        uuids = @same_pdh.map(&:uuid).to_a.uniq
+        preload_links_for_objects uuids
+        render 'hash_matches'
+        return
+      else
+        if Job.api_exists?(:index)
+          jobs_with = lambda do |conds|
+            Job.limit(RELATION_LIMIT).where(conds)
+              .results.sort_by { |j| j.finished_at || j.created_at }
+          end
+          @output_of = jobs_with.call(output: @object.portable_data_hash)
+          @log_of = jobs_with.call(log: @object.portable_data_hash)
+        end
+
+        @project_links = Link.limit(RELATION_LIMIT).order("modified_at DESC")
+          .where(head_uuid: @object.uuid, link_class: 'name').results
+        project_hash = Group.where(uuid: @project_links.map(&:tail_uuid)).to_hash
+        @projects = project_hash.values
+
+        @permissions = Link.limit(RELATION_LIMIT).order("modified_at DESC")
+          .where(head_uuid: @object.uuid, link_class: 'permission',
+                 name: 'can_read').results
+        @search_sharing = search_scopes
+
+        if params["tab_pane"] == "Used_by"
+          @used_by_svg = ProvenanceHelper::create_provenance_graph(@object.used_by, "used_by_svg",
+                                                                   {:request => request,
+                                                                    :direction => :top_down,
+                                                                    :combine_jobs => :script_only,
+                                                                    :pdata_only => true}) rescue nil
+        end
+      end
     end
     super
   end
 
   def sharing_popup
     @search_sharing = search_scopes
-    respond_to do |format|
-      format.html
-      format.js
-    end
+    render("sharing_popup.js", content_type: "text/javascript")
   end
 
   helper_method :download_link
 
   def download_link
-    collections_url + "/download/#{@object.uuid}/#{@search_sharing.first.api_token}/"
+    token = @search_sharing.first.api_token
+    if Rails.configuration.keep_web_url || Rails.configuration.keep_web_download_url
+      keep_web_url(@object.uuid, nil, {path_token: token})
+    else
+      collections_url + "/download/#{@object.uuid}/#{token}/"
+    end
   end
 
   def share
-    a = ApiClientAuthorization.create(scopes: sharing_scopes)
-    @search_sharing = search_scopes
-    render 'sharing_popup'
+    ApiClientAuthorization.create(scopes: sharing_scopes)
+    sharing_popup
   end
 
   def unshare
-    @search_sharing = search_scopes
-    @search_sharing.each do |s|
+    search_scopes.each do |s|
       s.destroy
     end
-    @search_sharing = search_scopes
-    render 'sharing_popup'
+    sharing_popup
+  end
+
+  def remove_selected_files
+    uuids, source_paths = selected_collection_files params
+
+    arv_coll = Arv::Collection.new(@object.manifest_text)
+    source_paths[uuids[0]].each do |p|
+      arv_coll.rm "."+p
+    end
+
+    if @object.update_attributes manifest_text: arv_coll.manifest_text
+      show
+    else
+      self.render_error status: 422
+    end
+  end
+
+  def update
+    updated_attr = params[:collection].each.select {|a| a[0].andand.start_with? 'rename-file-path:'}
+
+    if updated_attr.size > 0
+      # Is it file rename?
+      file_path = updated_attr[0][0].split('rename-file-path:')[-1]
+
+      new_file_path = updated_attr[0][1]
+      if new_file_path.start_with?('./')
+        # looks good
+      elsif new_file_path.start_with?('/')
+        new_file_path = '.' + new_file_path
+      else
+        new_file_path = './' + new_file_path
+      end
+
+      arv_coll = Arv::Collection.new(@object.manifest_text)
+
+      if arv_coll.exist?(new_file_path)
+        @errors = 'Duplicate file path. Please use a different name.'
+        self.render_error status: 422
+      else
+        arv_coll.rename "./"+file_path, new_file_path
+
+        if @object.update_attributes manifest_text: arv_coll.manifest_text
+          show
+        else
+          self.render_error status: 422
+        end
+      end
+    else
+      # Not a file rename; use default
+      super
+    end
+  end
+
+  def tags
+    render
+  end
+
+  def save_tags
+    tags_param = params['tag_data']
+    if tags_param
+      if tags_param.is_a?(String) && tags_param == "empty"
+        tags = {}
+      else
+        tags = tags_param
+      end
+    end
+
+    if tags
+      if @object.update_attributes properties: tags
+        @saved_tags = true
+        render
+      else
+        self.render_error status: 422
+      end
+    end
   end
 
   protected
@@ -271,7 +393,9 @@ class CollectionsController < ApplicationController
     most_specific_error = [401]
     token_list.each do |api_token|
       begin
-        using_specific_api_token(api_token) do
+        # We can't load the corresponding user, because the token may not
+        # be scoped for that.
+        using_specific_api_token(api_token, load_user: false) do
           yield
           return api_token
         end
@@ -290,7 +414,64 @@ class CollectionsController < ApplicationController
     return nil
   end
 
-  def file_enumerator(opts)
+  def keep_web_url(uuid_or_pdh, file, opts)
+    munged_id = uuid_or_pdh.sub('+', '-')
+    fmt = {uuid_or_pdh: munged_id}
+
+    tmpl = Rails.configuration.keep_web_url
+    if Rails.configuration.keep_web_download_url and
+        (!tmpl or opts[:disposition] == 'attachment')
+      # Prefer the attachment-only-host when we want an attachment
+      # (and when there is no preview link configured)
+      tmpl = Rails.configuration.keep_web_download_url
+    elsif not Rails.configuration.trust_all_content
+      check_uri = URI.parse(tmpl % fmt)
+      if opts[:query_token] and
+          not check_uri.host.start_with?(munged_id + "--") and
+          not check_uri.host.start_with?(munged_id + ".")
+        # We're about to pass a token in the query string, but
+        # keep-web can't accept that safely at a single-origin URL
+        # template (unless it's -attachment-only-host).
+        tmpl = Rails.configuration.keep_web_download_url
+        if not tmpl
+          raise ArgumentError, "Download precluded by site configuration"
+        end
+        logger.warn("Using download link, even though inline content " \
+                    "was requested: #{check_uri.to_s}")
+      end
+    end
+
+    if tmpl == Rails.configuration.keep_web_download_url
+      # This takes us to keep-web's -attachment-only-host so there is
+      # no need to add ?disposition=attachment.
+      opts.delete :disposition
+    end
+
+    uri = URI.parse(tmpl % fmt)
+    uri.path += '/' unless uri.path.end_with? '/'
+    if opts[:path_token]
+      uri.path += 't=' + opts[:path_token] + '/'
+    end
+    uri.path += '_/'
+    uri.path += URI.escape(file) if file
+
+    query = Hash[URI.decode_www_form(uri.query || '')]
+    { query_token: 'api_token',
+      disposition: 'disposition' }.each do |opt, param|
+      if opts.include? opt
+        query[param] = opts[opt]
+      end
+    end
+    unless query.empty?
+      uri.query = URI.encode_www_form(query)
+    end
+
+    uri.to_s
+  end
+
+  # Note: several controller and integration tests rely on stubbing
+  # file_enumerator to return fake file content.
+  def file_enumerator opts
     FileStreamer.new opts
   end
 
@@ -311,20 +492,18 @@ class CollectionsController < ApplicationController
       env['ARVADOS_API_HOST_INSECURE'] = "true" if Rails.configuration.arvados_insecure_https
 
       bytesleft = @opts[:maxbytes].andand.to_i || 2**16
-      Rails.logger.warn "@opts[:maxbytes] = #{@opts[:maxbytes]}, bytesleft = #{bytesleft}"
-      IO.popen([env, 'arv-get', "#{@opts[:uuid]}/#{@opts[:file]}"],
-               'rb') do |io|
-        bytecount = 0
-        while bytesleft > 0 && buf = io.read(bytesleft)
-          # shrink the bytesleft count, if we were given a
-          # maximum byte count to read
-          if @opts.include? :maxbytes
-            bytesleft = bytesleft - buf.length
-            Rails.logger.warn "bytesleft now #{bytesleft}"
-          end
-          yield buf
+      io = IO.popen([env, 'arv-get', "#{@opts[:uuid]}/#{@opts[:file]}"], 'rb')
+      while bytesleft > 0 && (buf = io.read([bytesleft, 2**16].min)) != nil
+        # shrink the bytesleft count, if we were given a maximum byte
+        # count to read
+        if @opts.include? :maxbytes
+          bytesleft = bytesleft - buf.length
         end
+        yield buf
       end
+      io.close
+      # "If ios is opened by IO.popen, close sets $?."
+      # http://www.ruby-doc.org/core-2.1.3/IO.html#method-i-close
       Rails.logger.warn("#{@opts[:uuid]}/#{@opts[:file]}: #{$?}") if $? != 0
     end
   end