Merge branch 'master' of git.curoverse.com:arvados
[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, except: ERROR_ACTIONS
11   around_filter :thread_with_optional_api_token
12   before_filter :check_user_agreements, except: ERROR_ACTIONS
13   before_filter :check_user_notifications, except: ERROR_ACTIONS
14   before_filter :find_object_by_uuid, except: [:index] + ERROR_ACTIONS
15   before_filter :check_my_folders, :except => 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 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).all
89     respond_to do |f|
90       f.json { render json: @objects }
91       f.html { render }
92       f.js { render }
93     end
94   end
95
96   def show
97     if !@object
98       return render_not_found("object not found")
99     end
100     respond_to do |f|
101       f.json { render json: @object.attributes.merge(href: url_for(@object)) }
102       f.html {
103         if request.method == 'GET'
104           render
105         else
106           redirect_to params[:return_to] || @object
107         end
108       }
109       f.js { render }
110     end
111   end
112
113   def render_content
114     if !@object
115       return render_not_found("object not found")
116     end
117   end
118
119   def new
120     @object = model_class.new
121   end
122
123   def update
124     updates = params[@object.class.to_s.underscore.singularize.to_sym]
125     updates.keys.each do |attr|
126       if @object.send(attr).is_a? Hash
127         if updates[attr].is_a? String
128           updates[attr] = Oj.load updates[attr]
129         end
130         if params[:merge] || params["merge_#{attr}".to_sym]
131           # Merge provided Hash with current Hash, instead of
132           # replacing.
133           updates[attr] = @object.send(attr).with_indifferent_access.
134             deep_merge(updates[attr].with_indifferent_access)
135         end
136       end
137     end
138     if @object.update_attributes updates
139       show
140     else
141       self.render_error status: 422
142     end
143   end
144
145   def create
146     @new_resource_attrs ||= params[model_class.to_s.underscore.singularize]
147     @new_resource_attrs ||= {}
148     @new_resource_attrs.reject! { |k,v| k.to_s == 'uuid' }
149     @object ||= model_class.new @new_resource_attrs
150     @object.save!
151     show
152   end
153
154   def destroy
155     if @object.destroy
156       respond_to do |f|
157         f.json { render json: @object }
158         f.html {
159           redirect_to(params[:return_to] || :back)
160         }
161         f.js { render }
162       end
163     else
164       self.render_error status: 422
165     end
166   end
167
168   def current_user
169     if Thread.current[:arvados_api_token]
170       Thread.current[:user] ||= User.current
171     else
172       logger.error "No API token in Thread"
173       return nil
174     end
175   end
176
177   def model_class
178     controller_name.classify.constantize
179   end
180
181   def breadcrumb_page_name
182     (@breadcrumb_page_name ||
183      (@object.friendly_link_name if @object.respond_to? :friendly_link_name) ||
184      action_name)
185   end
186
187   def index_pane_list
188     %w(Recent)
189   end
190
191   def show_pane_list
192     %w(Attributes Metadata JSON API)
193   end
194
195   protected
196
197   def redirect_to_login
198     respond_to do |f|
199       f.html {
200         if request.method == 'GET'
201           redirect_to arvados_api_client.arvados_login_url(return_to: request.url)
202         else
203           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."
204           redirect_to :back
205         end
206       }
207       f.json {
208         @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.']
209         self.render_error status: 422
210       }
211     end
212     false  # For convenience to return from callbacks
213   end
214
215   def using_specific_api_token(api_token)
216     start_values = {}
217     [:arvados_api_token, :user].each do |key|
218       start_values[key] = Thread.current[key]
219     end
220     Thread.current[:arvados_api_token] = api_token
221     Thread.current[:user] = nil
222     begin
223       yield
224     ensure
225       start_values.each_key { |key| Thread.current[key] = start_values[key] }
226     end
227   end
228
229   def find_object_by_uuid
230     if params[:id] and params[:id].match /\D/
231       params[:uuid] = params.delete :id
232     end
233     if not model_class
234       @object = nil
235     elsif params[:uuid].is_a? String
236       if params[:uuid].empty?
237         @object = nil
238       else
239         @object = model_class.find(params[:uuid])
240       end
241     else
242       @object = model_class.where(uuid: params[:uuid]).first
243     end
244   end
245
246   def thread_clear
247     Thread.current[:arvados_api_token] = nil
248     Thread.current[:user] = nil
249     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
250     yield
251     Rails.cache.delete_matched(/^request_#{Thread.current.object_id}_/)
252   end
253
254   def thread_with_api_token(login_optional = false)
255     begin
256       try_redirect_to_login = true
257       if params[:api_token]
258         try_redirect_to_login = false
259         Thread.current[:arvados_api_token] = params[:api_token]
260         # Before copying the token into session[], do a simple API
261         # call to verify its authenticity.
262         if verify_api_token
263           session[:arvados_api_token] = params[:api_token]
264           if !request.format.json? and request.method == 'GET'
265             # Repeat this request with api_token in the (new) session
266             # cookie instead of the query string.  This prevents API
267             # tokens from appearing in (and being inadvisedly copied
268             # and pasted from) browser Location bars.
269             redirect_to request.fullpath.sub(%r{([&\?]api_token=)[^&\?]*}, '')
270           else
271             yield
272           end
273         else
274           @errors = ['Invalid API token']
275           self.render_error status: 401
276         end
277       elsif session[:arvados_api_token]
278         # In this case, the token must have already verified at some
279         # point, but it might have been revoked since.  We'll try
280         # using it, and catch the exception if it doesn't work.
281         try_redirect_to_login = false
282         Thread.current[:arvados_api_token] = session[:arvados_api_token]
283         begin
284           yield
285         rescue ArvadosApiClient::NotLoggedInException
286           try_redirect_to_login = true
287         end
288       else
289         logger.debug "No token received, session is #{session.inspect}"
290       end
291       if try_redirect_to_login
292         unless login_optional
293           redirect_to_login
294         else
295           # login is optional for this route so go on to the regular controller
296           Thread.current[:arvados_api_token] = nil
297           yield
298         end
299       end
300     ensure
301       # Remove token in case this Thread is used for anything else.
302       Thread.current[:arvados_api_token] = nil
303     end
304   end
305
306   def thread_with_mandatory_api_token
307     thread_with_api_token do
308       yield
309     end
310   end
311
312   # This runs after thread_with_mandatory_api_token in the filter chain.
313   def thread_with_optional_api_token
314     if Thread.current[:arvados_api_token]
315       # We are already inside thread_with_mandatory_api_token.
316       yield
317     else
318       # We skipped thread_with_mandatory_api_token. Use the optional version.
319       thread_with_api_token(true) do
320         yield
321       end
322     end
323   end
324
325   def verify_api_token
326     begin
327       Link.where(uuid: 'just-verifying-my-api-token')
328       true
329     rescue ArvadosApiClient::NotLoggedInException
330       false
331     end
332   end
333
334   def ensure_current_user_is_admin
335     unless current_user and current_user.is_admin
336       @errors = ['Permission denied']
337       self.render_error status: 401
338     end
339   end
340
341   def check_user_agreements
342     if current_user && !current_user.is_active && current_user.is_invited
343       signatures = UserAgreement.signatures
344       @signed_ua_uuids = UserAgreement.signatures.map &:head_uuid
345       @required_user_agreements = UserAgreement.all.map do |ua|
346         if not @signed_ua_uuids.index ua.uuid
347           Collection.find(ua.uuid)
348         end
349       end.compact
350       if @required_user_agreements.empty?
351         # No agreements to sign. Perhaps we just need to ask?
352         current_user.activate
353         if !current_user.is_active
354           logger.warn "#{current_user.uuid.inspect}: " +
355             "No user agreements to sign, but activate failed!"
356         end
357       end
358       if !current_user.is_active
359         render 'user_agreements/index'
360       end
361     end
362     true
363   end
364
365   def select_theme
366     return Rails.configuration.arvados_theme
367   end
368
369   @@notification_tests = []
370
371   @@notification_tests.push lambda { |controller, current_user|
372     AuthorizedKey.limit(1).where(authorized_user_uuid: current_user.uuid).each do
373       return nil
374     end
375     return lambda { |view|
376       view.render partial: 'notifications/ssh_key_notification'
377     }
378   }
379
380   #@@notification_tests.push lambda { |controller, current_user|
381   #  Job.limit(1).where(created_by: current_user.uuid).each do
382   #    return nil
383   #  end
384   #  return lambda { |view|
385   #    view.render partial: 'notifications/jobs_notification'
386   #  }
387   #}
388
389   @@notification_tests.push lambda { |controller, current_user|
390     Collection.limit(1).where(created_by: current_user.uuid).each do
391       return nil
392     end
393     return lambda { |view|
394       view.render partial: 'notifications/collections_notification'
395     }
396   }
397
398   @@notification_tests.push lambda { |controller, current_user|
399     PipelineInstance.limit(1).where(created_by: current_user.uuid).each do
400       return nil
401     end
402     return lambda { |view|
403       view.render partial: 'notifications/pipelines_notification'
404     }
405   }
406
407   def check_my_folders
408     @my_top_level_folders = lambda do
409       @top_level_folders ||= Group.
410         filter([['group_class','=','folder'],
411                 ['owner_uuid','=',current_user.uuid]]).
412         sort_by { |x| x.name || '' }
413     end
414   end
415
416   def check_user_notifications
417     @notification_count = 0
418     @notifications = []
419
420     if current_user
421       @showallalerts = false
422       @@notification_tests.each do |t|
423         a = t.call(self, current_user)
424         if a
425           @notification_count += 1
426           @notifications.push a
427         end
428       end
429     end
430
431     if @notification_count == 0
432       @notification_count = ''
433     end
434   end
435 end