4233: will rescale y-axis for a series that scrolls off leaving less than fully scale...
[arvados.git] / apps / workbench / app / controllers / collections_controller.rb
1 require "arvados/keep"
2
3 class CollectionsController < ApplicationController
4   include ActionController::Live
5
6   skip_around_filter(:require_thread_api_token,
7                      only: [:show_file, :show_file_links])
8   skip_before_filter(:find_object_by_uuid,
9                      only: [:provenance, :show_file, :show_file_links])
10   # We depend on show_file to display the user agreement:
11   skip_before_filter :check_user_agreements, only: :show_file
12   skip_before_filter :check_user_profile, only: :show_file
13
14   RELATION_LIMIT = 5
15
16   def show_pane_list
17     %w(Files Provenance_graph Used_by Advanced)
18   end
19
20   def set_persistent
21     case params[:value]
22     when 'persistent', 'cache'
23       persist_links = Link.filter([['owner_uuid', '=', current_user.uuid],
24                                    ['link_class', '=', 'resources'],
25                                    ['name', '=', 'wants'],
26                                    ['tail_uuid', '=', current_user.uuid],
27                                    ['head_uuid', '=', @object.uuid]])
28       logger.debug persist_links.inspect
29     else
30       return unprocessable "Invalid value #{value.inspect}"
31     end
32     if params[:value] == 'persistent'
33       if not persist_links.any?
34         Link.create(link_class: 'resources',
35                     name: 'wants',
36                     tail_uuid: current_user.uuid,
37                     head_uuid: @object.uuid)
38       end
39     else
40       persist_links.each do |link|
41         link.destroy || raise
42       end
43     end
44
45     respond_to do |f|
46       f.json { render json: @object }
47     end
48   end
49
50   def choose
51     # Find collections using default find_objects logic, then search for name
52     # links, and preload any other links connected to the collections that are
53     # found.
54     # Name links will be obsolete when issue #3036 is merged,
55     # at which point this entire custom #choose function can probably be
56     # eliminated.
57
58     params[:limit] ||= 40
59
60     find_objects_for_index
61     @collections = @objects
62
63     @filters += [['link_class','=','name'],
64                  ['head_uuid','is_a','arvados#collection']]
65
66     @objects = Link
67     find_objects_for_index
68
69     @name_links = @objects
70
71     @objects = Collection.
72       filter([['uuid','in',@name_links.collect(&:head_uuid)]])
73
74     preload_links_for_objects (@collections.to_a + @objects.to_a)
75     super
76   end
77
78   def index
79     # API server index doesn't return manifest_text by default, but our
80     # callers want it unless otherwise specified.
81     @select ||= Collection.columns.map(&:name)
82     base_search = Collection.select(@select)
83     if params[:search].andand.length.andand > 0
84       tags = Link.where(any: ['contains', params[:search]])
85       @collections = (base_search.where(uuid: tags.collect(&:head_uuid)) |
86                       base_search.where(any: ['contains', params[:search]])).
87         uniq { |c| c.uuid }
88     else
89       if params[:limit]
90         limit = params[:limit].to_i
91       else
92         limit = 100
93       end
94
95       if params[:offset]
96         offset = params[:offset].to_i
97       else
98         offset = 0
99       end
100
101       @collections = base_search.limit(limit).offset(offset)
102     end
103     @links = Link.limit(1000).
104       where(head_uuid: @collections.collect(&:uuid))
105     @collection_info = {}
106     @collections.each do |c|
107       @collection_info[c.uuid] = {
108         tag_links: [],
109         wanted: false,
110         wanted_by_me: false,
111         provenance: [],
112         links: []
113       }
114     end
115     @links.each do |link|
116       @collection_info[link.head_uuid] ||= {}
117       info = @collection_info[link.head_uuid]
118       case link.link_class
119       when 'tag'
120         info[:tag_links] << link
121       when 'resources'
122         info[:wanted] = true
123         info[:wanted_by_me] ||= link.tail_uuid == current_user.uuid
124       when 'provenance'
125         info[:provenance] << link.name
126       end
127       info[:links] << link
128     end
129     @request_url = request.url
130
131     render_index
132   end
133
134   def show_file_links
135     Thread.current[:reader_tokens] = [params[:reader_token]]
136     return if false.equal?(find_object_by_uuid)
137     render layout: false
138   end
139
140   def show_file
141     # We pipe from arv-get to send the file to the user.  Before we start it,
142     # we ask the API server if the file actually exists.  This serves two
143     # purposes: it lets us return a useful status code for common errors, and
144     # helps us figure out which token to provide to arv-get.
145     coll = nil
146     tokens = [Thread.current[:arvados_api_token], params[:reader_token]].compact
147     usable_token = find_usable_token(tokens) do
148       coll = Collection.find(params[:uuid])
149     end
150
151     file_name = params[:file].andand.sub(/^(\.\/|\/|)/, './')
152     if usable_token.nil?
153       return  # Response already rendered.
154     elsif file_name.nil? or not coll.manifest.has_file?(file_name)
155       return render_not_found
156     end
157
158     opts = params.merge(arvados_api_token: usable_token)
159
160     # Handle Range requests. Currently we support only 'bytes=0-....'
161     if request.headers.include? 'HTTP_RANGE'
162       if m = /^bytes=0-(\d+)/.match(request.headers['HTTP_RANGE'])
163         opts[:maxbytes] = m[1]
164         size = params[:size] || '*'
165         self.response.status = 206
166         self.response.headers['Content-Range'] = "bytes 0-#{m[1]}/#{size}"
167       end
168     end
169
170     ext = File.extname(params[:file])
171     self.response.headers['Content-Type'] =
172       Rack::Mime::MIME_TYPES[ext] || 'application/octet-stream'
173     if params[:size]
174       size = params[:size].to_i
175       if opts[:maxbytes]
176         size = [size, opts[:maxbytes].to_i].min
177       end
178       self.response.headers['Content-Length'] = size.to_s
179     end
180     self.response.headers['Content-Disposition'] = params[:disposition] if params[:disposition]
181     begin
182       file_enumerator(opts).each do |bytes|
183         response.stream.write bytes
184       end
185     ensure
186       response.stream.close
187     end
188   end
189
190   def sharing_scopes
191     ["GET /arvados/v1/collections/#{@object.uuid}", "GET /arvados/v1/collections/#{@object.uuid}/", "GET /arvados/v1/keep_services/accessible"]
192   end
193
194   def search_scopes
195     begin
196       ApiClientAuthorization.filter([['scopes', '=', sharing_scopes]]).results
197     rescue ArvadosApiClient::AccessForbiddenException
198       nil
199     end
200   end
201
202   def find_object_by_uuid
203     if not Keep::Locator.parse params[:id]
204       super
205     end
206   end
207
208   def show
209     return super if !@object
210     if current_user
211       if Keep::Locator.parse params["uuid"]
212         @same_pdh = Collection.filter([["portable_data_hash", "=", @object.portable_data_hash]]).limit(1000)
213         if @same_pdh.results.size == 1
214           redirect_to collection_path(@same_pdh[0]["uuid"])
215           return
216         end
217         owners = @same_pdh.map(&:owner_uuid).to_a.uniq
218         preload_objects_for_dataclass Group, owners
219         preload_objects_for_dataclass User, owners
220         render 'hash_matches'
221         return
222       else
223         jobs_with = lambda do |conds|
224           Job.limit(RELATION_LIMIT).where(conds)
225             .results.sort_by { |j| j.finished_at || j.created_at }
226         end
227         @output_of = jobs_with.call(output: @object.portable_data_hash)
228         @log_of = jobs_with.call(log: @object.portable_data_hash)
229         @project_links = Link.limit(RELATION_LIMIT).order("modified_at DESC")
230           .where(head_uuid: @object.uuid, link_class: 'name').results
231         project_hash = Group.where(uuid: @project_links.map(&:tail_uuid)).to_hash
232         @projects = project_hash.values
233
234         @permissions = Link.limit(RELATION_LIMIT).order("modified_at DESC")
235           .where(head_uuid: @object.uuid, link_class: 'permission',
236                  name: 'can_read').results
237         @logs = Log.limit(RELATION_LIMIT).order("created_at DESC")
238           .where(object_uuid: @object.uuid).results
239         @is_persistent = Link.limit(1)
240           .where(head_uuid: @object.uuid, tail_uuid: current_user.uuid,
241                  link_class: 'resources', name: 'wants')
242           .results.any?
243         @search_sharing = search_scopes
244
245         if params["tab_pane"] == "Provenance_graph"
246           @prov_svg = ProvenanceHelper::create_provenance_graph(@object.provenance, "provenance_svg",
247                                                                 {:request => request,
248                                                                   :direction => :bottom_up,
249                                                                   :combine_jobs => :script_only}) rescue nil
250         end
251         if params["tab_pane"] == "Used_by"
252           @used_by_svg = ProvenanceHelper::create_provenance_graph(@object.used_by, "used_by_svg",
253                                                                    {:request => request,
254                                                                      :direction => :top_down,
255                                                                      :combine_jobs => :script_only,
256                                                                      :pdata_only => true}) rescue nil
257         end
258       end
259     end
260     super
261   end
262
263   def sharing_popup
264     @search_sharing = search_scopes
265     respond_to do |format|
266       format.html
267       format.js
268     end
269   end
270
271   helper_method :download_link
272
273   def download_link
274     collections_url + "/download/#{@object.uuid}/#{@search_sharing.first.api_token}/"
275   end
276
277   def share
278     a = ApiClientAuthorization.create(scopes: sharing_scopes)
279     @search_sharing = search_scopes
280     render 'sharing_popup'
281   end
282
283   def unshare
284     @search_sharing = search_scopes
285     @search_sharing.each do |s|
286       s.destroy
287     end
288     @search_sharing = search_scopes
289     render 'sharing_popup'
290   end
291
292   protected
293
294   def find_usable_token(token_list)
295     # Iterate over every given token to make it the current token and
296     # yield the given block.
297     # If the block succeeds, return the token it used.
298     # Otherwise, render an error response based on the most specific
299     # error we encounter, and return nil.
300     most_specific_error = [401]
301     token_list.each do |api_token|
302       begin
303         # We can't load the corresponding user, because the token may not
304         # be scoped for that.
305         using_specific_api_token(api_token, load_user: false) do
306           yield
307           return api_token
308         end
309       rescue ArvadosApiClient::ApiError => error
310         if error.api_status >= most_specific_error.first
311           most_specific_error = [error.api_status, error]
312         end
313       end
314     end
315     case most_specific_error.shift
316     when 401, 403
317       redirect_to_login
318     when 404
319       render_not_found(*most_specific_error)
320     end
321     return nil
322   end
323
324   def file_enumerator(opts)
325     FileStreamer.new opts
326   end
327
328   class FileStreamer
329     include ArvadosApiClientHelper
330     def initialize(opts={})
331       @opts = opts
332     end
333     def each
334       return unless @opts[:uuid] && @opts[:file]
335
336       env = Hash[ENV].dup
337
338       require 'uri'
339       u = URI.parse(arvados_api_client.arvados_v1_base)
340       env['ARVADOS_API_HOST'] = "#{u.host}:#{u.port}"
341       env['ARVADOS_API_TOKEN'] = @opts[:arvados_api_token]
342       env['ARVADOS_API_HOST_INSECURE'] = "true" if Rails.configuration.arvados_insecure_https
343
344       bytesleft = @opts[:maxbytes].andand.to_i || 2**16
345       io = IO.popen([env, 'arv-get', "#{@opts[:uuid]}/#{@opts[:file]}"], 'rb')
346       while bytesleft > 0 && (buf = io.read([bytesleft, 2**16].min)) != nil
347         # shrink the bytesleft count, if we were given a maximum byte
348         # count to read
349         if @opts.include? :maxbytes
350           bytesleft = bytesleft - buf.length
351         end
352         yield buf
353       end
354       io.close
355       # "If ios is opened by IO.popen, close sets $?."
356       # http://www.ruby-doc.org/core-2.1.3/IO.html#method-i-close
357       Rails.logger.warn("#{@opts[:uuid]}/#{@opts[:file]}: #{$?}") if $? != 0
358     end
359   end
360 end