Merge branch 'master' into 2751-python-sdk-keep-proxy-support
[arvados.git] / apps / workbench / app / controllers / application_controller.rb
1 class ApplicationController < ActionController::Base
2   include ArvadosApiClientHelper
3
4   respond_to :html, :json, :js
5   protect_from_forgery
6
7   ERROR_ACTIONS = [:render_error, :render_not_found]
8
9   around_filter :thread_clear
10   around_filter(:thread_with_mandatory_api_token,
11                 except: [:index, :show] + 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   around_filter :using_reader_tokens, only: [:index, :show]
16   before_filter :find_object_by_uuid, except: [:index] + ERROR_ACTIONS
17   before_filter :check_my_folders, :except => ERROR_ACTIONS
18   theme :select_theme
19
20   begin
21     rescue_from Exception,
22     :with => :render_exception
23     rescue_from ActiveRecord::RecordNotFound,
24     :with => :render_not_found
25     rescue_from ActionController::RoutingError,
26     :with => :render_not_found
27     rescue_from ActionController::UnknownController,
28     :with => :render_not_found
29     rescue_from ::AbstractController::ActionNotFound,
30     :with => :render_not_found
31   end
32
33   def unprocessable(message=nil)
34     @errors ||= []
35
36     @errors << message if message
37     render_error status: 422
38   end
39
40   def render_error(opts)
41     opts = {status: 500}.merge opts
42     respond_to do |f|
43       # json must come before html here, so it gets used as the
44       # default format when js is requested by the client. This lets
45       # ajax:error callback parse the response correctly, even though
46       # the browser can't.
47       f.json { render opts.merge(json: {success: false, errors: @errors}) }
48       f.html { render opts.merge(controller: 'application', action: 'error') }
49     end
50   end
51
52   def render_exception(e)
53     logger.error e.inspect
54     logger.error e.backtrace.collect { |x| x + "\n" }.join('') if e.backtrace
55     if @object.andand.errors.andand.full_messages.andand.any?
56       @errors = @object.errors.full_messages
57     else
58       @errors = [e.to_s]
59     end
60     self.render_error status: 422
61   end
62
63   def render_not_found(e=ActionController::RoutingError.new("Path not found"))
64     logger.error e.inspect
65     @errors = ["Path not found"]
66     self.render_error status: 404
67   end
68
69   def index
70     @limit ||= 200
71     if params[:limit]
72       @limit = params[:limit].to_i
73     end
74
75     @offset ||= 0
76     if params[:offset]
77       @offset = params[:offset].to_i
78     end
79
80     @filters ||= []
81     if params[:filters]
82       filters = params[:filters]
83       if filters.is_a? String
84         filters = Oj.load filters
85       end
86       @filters += filters
87     end
88
89     @objects ||= model_class
90     @objects = @objects.filter(@filters).limit(@limit).offset(@offset).all
91     respond_to do |f|
92       f.json { render json: @objects }
93       f.html { render }
94       f.js { render }
95     end
96   end
97
98   def show
99     if !@object
100       return render_not_found("object not found")
101     end
102     respond_to do |f|
103       f.json { render json: @object.attributes.merge(href: url_for(@object)) }
104       f.html {
105         if request.method == 'GET'
106           render
107         else
108           redirect_to params[:return_to] || @object
109         end
110       }
111       f.js { render }
112     end
113   end
114
115   def render_content
116     if !@object
117       return render_not_found("object not found")
118     end
119   end
120
121   def new
122     @object = model_class.new
123   end
124
125   def update
126     updates = params[@object.class.to_s.underscore.singularize.to_sym]
127     updates.keys.each do |attr|
128       if @object.send(attr).is_a? Hash
129         if updates[attr].is_a? String
130           updates[attr] = Oj.load updates[attr]
131         end
132         if params[:merge] || params["merge_#{attr}".to_sym]
133           # Merge provided Hash with current Hash, instead of
134           # replacing.
135           updates[attr] = @object.send(attr).with_indifferent_access.
136             deep_merge(updates[attr].with_indifferent_access)
137         end
138       end
139     end
140     if @object.update_attributes updates
141       show
142     else
143       self.render_error status: 422
144     end
145   end
146
147   def create
148     @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
149     @new_resource_attrs ||= {}
150     @new_resource_attrs.reject! { |k,v| k.to_s == 'uuid' }
151     @object ||= model_class.new @new_resource_attrs
152     @object.save!
153     show
154   end
155
156   def destroy
157     if @object.destroy
158       respond_to do |f|
159         f.json { render json: @object }
160         f.html {
161           redirect_to(params[:return_to] || :back)
162         }
163         f.js { render }
164       end
165     else
166       self.render_error status: 422
167     end
168   end
169
170   def current_user
171     if Thread.current[:arvados_api_token]
172       Thread.current[:user] ||= User.current
173     else
174       logger.error "No API token in Thread"
175       return nil
176     end
177   end
178
179   def model_class
180     controller_name.classify.constantize
181   end
182
183   def breadcrumb_page_name
184     (@breadcrumb_page_name ||
185      (@object.friendly_link_name if @object.respond_to? :friendly_link_name) ||
186      action_name)
187   end
188
189   def index_pane_list
190     %w(Recent)
191   end
192
193   def show_pane_list
194     %w(Attributes Metadata JSON API)
195   end
196
197   protected
198
199   def redirect_to_login
200     respond_to do |f|
201       f.html {
202         if request.method == 'GET'
203           redirect_to arvados_api_client.arvados_login_url(return_to: request.url)
204         else
205           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."
206           redirect_to :back
207         end
208       }
209       f.json {
210         @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.']
211         self.render_error status: 422
212       }
213     end
214     false  # For convenience to return from callbacks
215   end
216
217   def using_reader_tokens(login_optional=false)
218     if params[:reader_tokens].is_a?(Array) and params[:reader_tokens].any?
219       Thread.current[:reader_tokens] = params[:reader_tokens]
220     end
221     begin
222       yield
223     rescue ArvadosApiClient::NotLoggedInException
224       if login_optional
225         raise
226       else
227         return redirect_to_login
228       end
229     ensure
230       Thread.current[:reader_tokens] = nil
231     end
232   end
233
234   def using_specific_api_token(api_token)
235     start_values = {}
236     [:arvados_api_token, :user].each do |key|
237       start_values[key] = Thread.current[key]
238     end
239     Thread.current[:arvados_api_token] = api_token
240     Thread.current[:user] = nil
241     begin
242       yield
243     ensure
244       start_values.each_key { |key| Thread.current[key] = start_values[key] }
245     end
246   end
247
248   def find_object_by_uuid
249     if params[:id] and params[:id].match /\D/
250       params[:uuid] = params.delete :id
251     end
252     if not model_class
253       @object = nil
254     elsif params[:uuid].is_a? String
255       if params[:uuid].empty?
256         @object = nil
257       else
258         @object = model_class.find(params[:uuid])
259       end
260     else
261       @object = model_class.where(uuid: params[:uuid]).first
262     end
263   end
264
265   def thread_clear
266     Thread.current[:arvados_api_token] = nil
267     Thread.current[:user] = nil
268     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
269     yield
270     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
271   end
272
273   def thread_with_api_token(login_optional = false)
274     begin
275       try_redirect_to_login = true
276       if params[:api_token]
277         try_redirect_to_login = false
278         Thread.current[:arvados_api_token] = params[:api_token]
279         # Before copying the token into session[], do a simple API
280         # call to verify its authenticity.
281         if verify_api_token
282           session[:arvados_api_token] = params[:api_token]
283           if !request.format.json? and request.method == 'GET'
284             # Repeat this request with api_token in the (new) session
285             # cookie instead of the query string.  This prevents API
286             # tokens from appearing in (and being inadvisedly copied
287             # and pasted from) browser Location bars.
288             redirect_to request.fullpath.sub(%r{([&\?]api_token=)[^&\?]*}, '')
289           else
290             yield
291           end
292         else
293           @errors = ['Invalid API token']
294           self.render_error status: 401
295         end
296       elsif session[:arvados_api_token]
297         # In this case, the token must have already verified at some
298         # point, but it might have been revoked since.  We'll try
299         # using it, and catch the exception if it doesn't work.
300         try_redirect_to_login = false
301         Thread.current[:arvados_api_token] = session[:arvados_api_token]
302         begin
303           yield
304         rescue ArvadosApiClient::NotLoggedInException
305           try_redirect_to_login = true
306         end
307       else
308         logger.debug "No token received, session is #{session.inspect}"
309       end
310       if try_redirect_to_login
311         unless login_optional
312           redirect_to_login
313         else
314           # login is optional for this route so go on to the regular controller
315           Thread.current[:arvados_api_token] = nil
316           yield
317         end
318       end
319     ensure
320       # Remove token in case this Thread is used for anything else.
321       Thread.current[:arvados_api_token] = nil
322     end
323   end
324
325   def thread_with_mandatory_api_token
326     thread_with_api_token do
327       yield
328     end
329   end
330
331   # This runs after thread_with_mandatory_api_token in the filter chain.
332   def thread_with_optional_api_token
333     if Thread.current[:arvados_api_token]
334       # We are already inside thread_with_mandatory_api_token.
335       yield
336     else
337       # We skipped thread_with_mandatory_api_token. Use the optional version.
338       thread_with_api_token(true) do
339         yield
340       end
341     end
342   end
343
344   def verify_api_token
345     begin
346       Link.where(uuid: 'just-verifying-my-api-token')
347       true
348     rescue ArvadosApiClient::NotLoggedInException
349       false
350     end
351   end
352
353   def ensure_current_user_is_admin
354     unless current_user and current_user.is_admin
355       @errors = ['Permission denied']
356       self.render_error status: 401
357     end
358   end
359
360   def check_user_agreements
361     if current_user && !current_user.is_active && current_user.is_invited
362       signatures = UserAgreement.signatures
363       @signed_ua_uuids = UserAgreement.signatures.map &:head_uuid
364       @required_user_agreements = UserAgreement.all.map do |ua|
365         if not @signed_ua_uuids.index ua.uuid
366           Collection.find(ua.uuid)
367         end
368       end.compact
369       if @required_user_agreements.empty?
370         # No agreements to sign. Perhaps we just need to ask?
371         current_user.activate
372         if !current_user.is_active
373           logger.warn "#{current_user.uuid.inspect}: " +
374             "No user agreements to sign, but activate failed!"
375         end
376       end
377       if !current_user.is_active
378         render 'user_agreements/index'
379       end
380     end
381     true
382   end
383
384   def select_theme
385     return Rails.configuration.arvados_theme
386   end
387
388   @@notification_tests = []
389
390   @@notification_tests.push lambda { |controller, current_user|
391     AuthorizedKey.limit(1).where(authorized_user_uuid: current_user.uuid).each do
392       return nil
393     end
394     return lambda { |view|
395       view.render partial: 'notifications/ssh_key_notification'
396     }
397   }
398
399   #@@notification_tests.push lambda { |controller, current_user|
400   #  Job.limit(1).where(created_by: current_user.uuid).each do
401   #    return nil
402   #  end
403   #  return lambda { |view|
404   #    view.render partial: 'notifications/jobs_notification'
405   #  }
406   #}
407
408   @@notification_tests.push lambda { |controller, current_user|
409     Collection.limit(1).where(created_by: current_user.uuid).each do
410       return nil
411     end
412     return lambda { |view|
413       view.render partial: 'notifications/collections_notification'
414     }
415   }
416
417   @@notification_tests.push lambda { |controller, current_user|
418     PipelineInstance.limit(1).where(created_by: current_user.uuid).each do
419       return nil
420     end
421     return lambda { |view|
422       view.render partial: 'notifications/pipelines_notification'
423     }
424   }
425
426   def check_my_folders
427     @my_top_level_folders = lambda do
428       @top_level_folders ||= Group.
429         filter([['group_class','=','folder'],
430                 ['owner_uuid','=',current_user.uuid]]).
431         sort_by { |x| x.name || '' }
432     end
433   end
434
435   def check_user_notifications
436     @notification_count = 0
437     @notifications = []
438
439     if current_user
440       @showallalerts = false
441       @@notification_tests.each do |t|
442         a = t.call(self, current_user)
443         if a
444           @notification_count += 1
445           @notifications.push a
446         end
447       end
448     end
449
450     if @notification_count == 0
451       @notification_count = ''
452     end
453   end
454 end