2872: Fix bookmark bar causing spurious window width.
[arvados.git] / apps / workbench / app / controllers / application_controller.rb
1 class ApplicationController < ActionController::Base
2   include ArvadosApiClientHelper
3   include ApplicationHelper
4
5   respond_to :html, :json, :js
6   protect_from_forgery
7
8   ERROR_ACTIONS = [:render_error, :render_not_found]
9
10   around_filter :thread_clear
11   around_filter :thread_with_mandatory_api_token, except: ERROR_ACTIONS
12   around_filter :thread_with_optional_api_token
13   before_filter :check_user_agreements, except: ERROR_ACTIONS
14   before_filter :check_user_notifications, except: ERROR_ACTIONS
15   before_filter :find_object_by_uuid, except: [:index] + ERROR_ACTIONS
16   theme :select_theme
17
18   begin
19     rescue_from Exception,
20     :with => :render_exception
21     rescue_from ActiveRecord::RecordNotFound,
22     :with => :render_not_found
23     rescue_from ActionController::RoutingError,
24     :with => :render_not_found
25     rescue_from ActionController::UnknownController,
26     :with => :render_not_found
27     rescue_from ::AbstractController::ActionNotFound,
28     :with => :render_not_found
29   end
30
31   def unprocessable(message=nil)
32     @errors ||= []
33
34     @errors << message if message
35     render_error status: 422
36   end
37
38   def render_error(opts)
39     opts = {status: 500}.merge opts
40     respond_to do |f|
41       # json must come before html here, so it gets used as the
42       # default format when js is requested by the client. This lets
43       # ajax:error callback parse the response correctly, even though
44       # the browser can't.
45       f.json { render opts.merge(json: {success: false, errors: @errors}) }
46       f.html { render opts.merge(controller: 'application', action: 'error') }
47     end
48   end
49
50   def render_exception(e)
51     logger.error e.inspect
52     logger.error e.backtrace.collect { |x| x + "\n" }.join('') if e.backtrace
53     if @object.andand.errors.andand.full_messages.andand.any?
54       @errors = @object.errors.full_messages
55     else
56       @errors = [e.to_s]
57     end
58     self.render_error status: 422
59   end
60
61   def render_not_found(e=ActionController::RoutingError.new("Path not found"))
62     logger.error e.inspect
63     @errors = ["Path not found"]
64     self.render_error status: 404
65   end
66
67   def find_objects_for_index
68     @limit ||= 200
69     if params[:limit]
70       @limit = params[:limit].to_i
71     end
72
73     @offset ||= 0
74     if params[:offset]
75       @offset = params[:offset].to_i
76     end
77
78     @filters ||= []
79     if params[:filters]
80       filters = params[:filters]
81       if filters.is_a? String
82         filters = Oj.load filters
83       end
84       @filters += filters
85     end
86
87     @objects ||= model_class
88     @objects = @objects.filter(@filters).limit(@limit).offset(@offset)
89   end
90
91   helper_method :next_page_offset
92   def next_page_offset
93     if @objects.respond_to?(:result_offset) and
94         @objects.respond_to?(:result_limit) and
95         @objects.respond_to?(:items_available)
96       next_offset = @objects.result_offset + @objects.result_limit
97       if next_offset < @objects.items_available
98         next_offset
99       else
100         nil
101       end
102     end
103   end
104
105   def index
106     find_objects_for_index if !@objects
107     respond_to do |f|
108       f.json { render json: @objects }
109       f.html { render }
110       f.js { render }
111     end
112   end
113
114   def show
115     if !@object
116       return render_not_found("object not found")
117     end
118     respond_to do |f|
119       f.json { render json: @object.attributes.merge(href: url_for(@object)) }
120       f.html {
121         if request.method.in? ['GET', 'HEAD']
122           render
123         else
124           redirect_to params[:return_to] || @object
125         end
126       }
127       f.js { render }
128     end
129   end
130
131   def choose
132     params[:limit] ||= 20
133     find_objects_for_index if !@objects
134     respond_to do |f|
135       if params[:partial]
136         f.json {
137           render json: {
138             content: render_to_string(partial: "choose_rows.html",
139                                       formats: [:html],
140                                       locals: {
141                                         multiple: params[:multiple]
142                                       }),
143             next_page_href: @next_page_href
144           }
145         }
146       end
147       f.js {
148         render partial: 'choose', locals: {multiple: params[:multiple]}
149       }
150     end
151   end
152
153   def render_content
154     if !@object
155       return render_not_found("object not found")
156     end
157   end
158
159   def new
160     @object = model_class.new
161   end
162
163   def update
164     @updates ||= params[@object.resource_param_name.to_sym]
165     @updates.keys.each do |attr|
166       if @object.send(attr).is_a? Hash
167         if @updates[attr].is_a? String
168           @updates[attr] = Oj.load @updates[attr]
169         end
170         if params[:merge] || params["merge_#{attr}".to_sym]
171           # Merge provided Hash with current Hash, instead of
172           # replacing.
173           @updates[attr] = @object.send(attr).with_indifferent_access.
174             deep_merge(@updates[attr].with_indifferent_access)
175         end
176       end
177     end
178     if @object.update_attributes @updates
179       show
180     else
181       self.render_error status: 422
182     end
183   end
184
185   def create
186     @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
187     @new_resource_attrs ||= {}
188     @new_resource_attrs.reject! { |k,v| k.to_s == 'uuid' }
189     @object ||= model_class.new @new_resource_attrs
190     @object.save!
191     show
192   end
193
194   # Clone the given object, merging any attribute values supplied as
195   # with a create action.
196   def copy
197     @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
198     @new_resource_attrs ||= {}
199     @object = @object.dup
200     @object.update_attributes @new_resource_attrs
201     if not @new_resource_attrs[:name] and @object.respond_to? :name
202       if @object.name and @object.name != ''
203         @object.name = "Copy of #{@object.name}"
204       else
205         @object.name = "Copy of unnamed #{@object.class_for_display.downcase}"
206       end
207     end
208     @object.save!
209     show
210   end
211
212   def destroy
213     if @object.destroy
214       respond_to do |f|
215         f.json { render json: @object }
216         f.html {
217           redirect_to(params[:return_to] || :back)
218         }
219         f.js { render }
220       end
221     else
222       self.render_error status: 422
223     end
224   end
225
226   def current_user
227     if Thread.current[:arvados_api_token]
228       Thread.current[:user] ||= User.current
229     else
230       logger.error "No API token in Thread"
231       return nil
232     end
233   end
234
235   def model_class
236     controller_name.classify.constantize
237   end
238
239   def breadcrumb_page_name
240     (@breadcrumb_page_name ||
241      (@object.friendly_link_name if @object.respond_to? :friendly_link_name) ||
242      action_name)
243   end
244
245   def index_pane_list
246     %w(Recent)
247   end
248
249   def show_pane_list
250     %w(Attributes Advanced)
251   end
252
253   protected
254
255   def redirect_to_login
256     respond_to do |f|
257       f.html {
258         if request.method.in? ['GET', 'HEAD']
259           redirect_to arvados_api_client.arvados_login_url(return_to: request.url)
260         else
261           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."
262           redirect_to :back
263         end
264       }
265       f.json {
266         @errors = ['You do not seem to be logged in. You did not supply an API token with this request, and your session (if any) has timed out.']
267         self.render_error status: 422
268       }
269     end
270     false  # For convenience to return from callbacks
271   end
272
273   def using_specific_api_token(api_token)
274     start_values = {}
275     [:arvados_api_token, :user].each do |key|
276       start_values[key] = Thread.current[key]
277     end
278     Thread.current[:arvados_api_token] = api_token
279     Thread.current[:user] = nil
280     begin
281       yield
282     ensure
283       start_values.each_key { |key| Thread.current[key] = start_values[key] }
284     end
285   end
286
287   def find_object_by_uuid
288     if params[:id] and params[:id].match /\D/
289       params[:uuid] = params.delete :id
290     end
291     if not model_class
292       @object = nil
293     elsif params[:uuid].is_a? String
294       if params[:uuid].empty?
295         @object = nil
296       else
297         if (model_class != Link and
298             resource_class_for_uuid(params[:uuid]) == Link)
299           @name_link = Link.find(params[:uuid])
300           @object = model_class.find(@name_link.head_uuid)
301         else
302           @object = model_class.find(params[:uuid])
303         end
304       end
305     else
306       @object = model_class.where(uuid: params[:uuid]).first
307     end
308   end
309
310   def thread_clear
311     Thread.current[:arvados_api_token] = nil
312     Thread.current[:user] = nil
313     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
314     yield
315     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
316   end
317
318   def thread_with_api_token(login_optional = false)
319     begin
320       try_redirect_to_login = true
321       if params[:api_token]
322         try_redirect_to_login = false
323         Thread.current[:arvados_api_token] = params[:api_token]
324         # Before copying the token into session[], do a simple API
325         # call to verify its authenticity.
326         if verify_api_token
327           session[:arvados_api_token] = params[:api_token]
328           if !request.format.json? and request.method.in? ['GET', 'HEAD']
329             # Repeat this request with api_token in the (new) session
330             # cookie instead of the query string.  This prevents API
331             # tokens from appearing in (and being inadvisedly copied
332             # and pasted from) browser Location bars.
333             redirect_to request.fullpath.sub(%r{([&\?]api_token=)[^&\?]*}, '')
334           else
335             yield
336           end
337         else
338           @errors = ['Invalid API token']
339           self.render_error status: 401
340         end
341       elsif session[:arvados_api_token]
342         # In this case, the token must have already verified at some
343         # point, but it might have been revoked since.  We'll try
344         # using it, and catch the exception if it doesn't work.
345         try_redirect_to_login = false
346         Thread.current[:arvados_api_token] = session[:arvados_api_token]
347         begin
348           yield
349         rescue ArvadosApiClient::NotLoggedInException
350           try_redirect_to_login = true
351         end
352       else
353         logger.debug "No token received, session is #{session.inspect}"
354       end
355       if try_redirect_to_login
356         unless login_optional
357           redirect_to_login
358         else
359           # login is optional for this route so go on to the regular controller
360           Thread.current[:arvados_api_token] = nil
361           yield
362         end
363       end
364     ensure
365       # Remove token in case this Thread is used for anything else.
366       Thread.current[:arvados_api_token] = nil
367     end
368   end
369
370   def thread_with_mandatory_api_token
371     thread_with_api_token(true) do
372       if Thread.current[:arvados_api_token]
373         yield
374       elsif session[:arvados_api_token]
375         # Expired session. Clear it before refreshing login so that,
376         # if this login procedure fails, we end up showing the "please
377         # log in" page instead of getting stuck in a redirect loop.
378         session.delete :arvados_api_token
379         redirect_to_login
380       else
381         render 'users/welcome'
382       end
383     end
384   end
385
386   # This runs after thread_with_mandatory_api_token in the filter chain.
387   def thread_with_optional_api_token
388     if Thread.current[:arvados_api_token]
389       # We are already inside thread_with_mandatory_api_token.
390       yield
391     else
392       # We skipped thread_with_mandatory_api_token. Use the optional version.
393       thread_with_api_token(true) do
394         yield
395       end
396     end
397   end
398
399   def verify_api_token
400     begin
401       Link.where(uuid: 'just-verifying-my-api-token')
402       true
403     rescue ArvadosApiClient::NotLoggedInException
404       false
405     end
406   end
407
408   def ensure_current_user_is_admin
409     unless current_user and current_user.is_admin
410       @errors = ['Permission denied']
411       self.render_error status: 401
412     end
413   end
414
415   def check_user_agreements
416     if current_user && !current_user.is_active
417       if not current_user.is_invited
418         return render 'users/inactive'
419       end
420       signatures = UserAgreement.signatures
421       @signed_ua_uuids = UserAgreement.signatures.map &:head_uuid
422       @required_user_agreements = UserAgreement.all.map do |ua|
423         if not @signed_ua_uuids.index ua.uuid
424           Collection.find(ua.uuid)
425         end
426       end.compact
427       if @required_user_agreements.empty?
428         # No agreements to sign. Perhaps we just need to ask?
429         current_user.activate
430         if !current_user.is_active
431           logger.warn "#{current_user.uuid.inspect}: " +
432             "No user agreements to sign, but activate failed!"
433         end
434       end
435       if !current_user.is_active
436         render 'user_agreements/index'
437       end
438     end
439     true
440   end
441
442   def select_theme
443     return Rails.configuration.arvados_theme
444   end
445
446   @@notification_tests = []
447
448   @@notification_tests.push lambda { |controller, current_user|
449     AuthorizedKey.limit(1).where(authorized_user_uuid: current_user.uuid).each do
450       return nil
451     end
452     return lambda { |view|
453       view.render partial: 'notifications/ssh_key_notification'
454     }
455   }
456
457   #@@notification_tests.push lambda { |controller, current_user|
458   #  Job.limit(1).where(created_by: current_user.uuid).each do
459   #    return nil
460   #  end
461   #  return lambda { |view|
462   #    view.render partial: 'notifications/jobs_notification'
463   #  }
464   #}
465
466   @@notification_tests.push lambda { |controller, current_user|
467     Collection.limit(1).where(created_by: current_user.uuid).each do
468       return nil
469     end
470     return lambda { |view|
471       view.render partial: 'notifications/collections_notification'
472     }
473   }
474
475   @@notification_tests.push lambda { |controller, current_user|
476     PipelineInstance.limit(1).where(created_by: current_user.uuid).each do
477       return nil
478     end
479     return lambda { |view|
480       view.render partial: 'notifications/pipelines_notification'
481     }
482   }
483
484   def check_user_notifications
485     @notification_count = 0
486     @notifications = []
487
488     if current_user
489       @showallalerts = false
490       @@notification_tests.each do |t|
491         a = t.call(self, current_user)
492         if a
493           @notification_count += 1
494           @notifications.push a
495         end
496       end
497     end
498
499     if @notification_count == 0
500       @notification_count = ''
501     end
502   end
503
504   helper_method :all_projects
505   def all_projects
506     @all_projects ||= Group.filter([['group_class','in',['project','folder']]])
507   end
508
509   helper_method :my_projects
510   def my_projects
511     return @my_projects if @my_projects
512     @my_projects = []
513     root_of = {}
514     all_projects.each do |g|
515       root_of[g.uuid] = g.owner_uuid
516       @my_projects << g
517     end
518     done = false
519     while not done
520       done = true
521       root_of = root_of.each_with_object({}) do |(child, parent), h|
522         if root_of[parent]
523           h[child] = root_of[parent]
524           done = false
525         else
526           h[child] = parent
527         end
528       end
529     end
530     @my_projects = @my_projects.select do |g|
531       root_of[g.uuid] == current_user.uuid
532     end
533   end
534
535   helper_method :projects_shared_with_me
536   def projects_shared_with_me
537     my_project_uuids = my_projects.collect &:uuid
538     all_projects.reject { |x| x.uuid.in? my_project_uuids }
539   end
540
541   helper_method :recent_jobs_and_pipelines
542   def recent_jobs_and_pipelines
543     in_my_projects = ['owner_uuid','in',my_projects.collect(&:uuid)]
544     (Job.limit(10).filter([in_my_projects]) |
545      PipelineInstance.limit(10).filter([in_my_projects])).
546       sort_by do |x|
547       x.finished_at || x.started_at || x.created_at rescue x.created_at
548     end
549   end
550
551   helper_method :get_object
552   def get_object uuid
553     if @get_object.nil? and @objects
554       @get_object = @objects.each_with_object({}) do |object, h|
555         h[object.uuid] = object
556       end
557     end
558     @get_object ||= {}
559     @get_object[uuid]
560   end
561
562   helper_method :project_breadcrumbs
563   def project_breadcrumbs
564     crumbs = []
565     current = @name_link || @object
566     while current
567       if current.is_a?(Group) and current.group_class.in?(['project','folder'])
568         crumbs.prepend current
569       end
570       if current.is_a? Link
571         current = Group.find?(current.tail_uuid)
572       else
573         current = Group.find?(current.owner_uuid)
574       end
575     end
576     crumbs
577   end
578
579   helper_method :current_project_uuid
580   def current_project_uuid
581     if @object.is_a? Group and @object.group_class.in?(['project','folder'])
582       @object.uuid
583     elsif @name_link.andand.tail_uuid
584       @name_link.tail_uuid
585     elsif @object and resource_class_for_uuid(@object.owner_uuid) == Group
586       @object.owner_uuid
587     else
588       nil
589     end
590   end
591 end