[api] expand xforward syntax correctly
[opensuse:build-service.git] / src / api / app / controllers / application_controller.rb
1 # Filters added to this controller will be run for all controllers in the application.
2 # Likewise, all the methods added will be available for all controllers.
3
4 require 'opensuse/permission'
5 require 'opensuse/backend'
6 require 'opensuse/validator'
7 require 'xpath_engine'
8 require 'rexml/document'
9
10 class InvalidHttpMethodError < Exception; end
11 class MissingParameterError < Exception; end
12
13 class ApplicationController < ActionController::Base
14
15   # Do never use a layout here since that has impact on every controller
16   layout nil
17   # session :disabled => true
18
19   @user_permissions = nil
20   @http_user = nil
21
22   helper RbacHelper
23
24   before_filter :validate_incoming_xml, :add_api_version
25
26   if Rails.env.test?
27     before_filter :start_test_backend
28   end
29
30   # skip the filter for the user stuff
31   before_filter :extract_user, :except => :register
32   before_filter :setup_backend, :add_api_version, :restrict_admin_pages
33   before_filter :shutup_rails
34
35   #contains current authentification method, one of (:ichain, :basic)
36   attr_accessor :auth_method
37   
38   hide_action :auth_method
39   hide_action 'auth_method='
40
41   @@backend = nil
42   def start_test_backend
43     return if @@backend
44     logger.debug "Starting test backend..."
45     @@backend = IO.popen("#{RAILS_ROOT}/script/start_test_backend")
46     logger.debug "Test backend started with pid: #{@@backend.pid}"
47     while true do
48       line = @@backend.gets
49       raise RuntimeError.new('Backend died') unless line
50       break if line =~ /DONE NOW/
51       logger.debug line.strip
52     end
53     ActiveXML::Config.global_write_through = true
54     at_exit do
55       logger.debug "kill #{@@backend.pid}"
56       Process.kill "INT", @@backend.pid
57       @@backend = nil
58     end
59   end
60   hide_action :start_test_backend
61
62   protected
63   def restrict_admin_pages
64      if params[:controller] =~ /^active_rbac/ or params[:controller] =~ /^admin/
65         return require_admin
66      end
67   end
68
69   def require_admin
70     logger.debug "Checking for  Admin role for user #{@http_user.login}"
71     unless @http_user.has_role? 'Admin'
72       logger.debug "not granted!"
73       render :template => 'permerror'
74       return false
75     end
76     return true
77   end
78
79   def extract_user
80     if ICHAIN_MODE == :on || ICHAIN_MODE == :simulate # configured in the the environment file
81       @auth_method = :ichain
82       ichain_user = request.env['HTTP_X_USERNAME']
83       if ichain_user
84         logger.info "iChain user extracted from header: #{ichain_user}"
85       elsif ICHAIN_MODE == :simulate
86         ichain_user = ICHAIN_TEST_USER
87         logger.debug "iChain user extracted from config: #{ichain_user}"
88       end
89
90       # we're using iChain, there is no need to authenticate the user from the credentials
91       # However we have to care for the status of the user that must not be unconfirmed or ichain requested
92       if ichain_user
93         @http_user = User.find :first, :conditions => [ 'login = ? AND state=2', ichain_user ]
94         @http_user.update_user_info_from_ichain_env(request.env) unless @http_user.nil?
95
96         # If we do not find a User here, we need to create a user and wait for
97         # the confirmation by the user and the BS Admin Team.
98         if @http_user == nil
99           @http_user = User.find :first, :conditions => ['login = ?', ichain_user ]
100           if @http_user == nil
101             render_error :message => "iChain user not yet registered", :status => 403,
102               :errorcode => "unregistered_ichain_user",
103               :details => "Please register your user via the web application #{CONFIG['webui_url']} once."
104           else
105             if @http_user.state == 5 or @http_user.state == 1
106               render_error :message => "iChain user #{ichain_user} is registered but not yet approved.", :status => 403,
107                 :errorcode => "registered_ichain_but_unapproved",
108                 :details => "<p>Your account is a registered iChain account, but it is not yet approved for the buildservice.</p>"+
109                 "<p>Please stay tuned until you get approval message.</p>"
110             else
111               render_error :message => "Your user is either invalid or net yet confirmed (state #{@http_user.state}).",
112                 :status => 403,
113                 :errorcode => "unconfirmed_user",
114                 :details => "Please contact the openSUSE admin team <admin@opensuse.org>"
115             end
116           end
117           return false
118         end
119       else
120         if CONFIG['allow_anonymous']
121           @http_user = User.find_by_login( "_nobody_" )
122           @user_permissions = Suse::Permission.new( @http_user )
123           return true
124         end
125         logger.error "No X-username header from iChain! Are we really using iChain?"
126         render_error( :message => "No iChain user found!", :status => 401 ) and return false
127       end
128     else
129       #active_rbac is used for authentication
130       @auth_method = :basic
131
132       if request.env.has_key? 'X-HTTP_AUTHORIZATION'
133         # try to get it where mod_rewrite might have put it
134         authorization = request.env['X-HTTP_AUTHORIZATION'].to_s.split
135       elsif request.env.has_key? 'Authorization'
136         # for Apace/mod_fastcgi with -pass-header Authorization
137         authorization = request.env['Authorization'].to_s.split
138       elsif request.env.has_key? 'HTTP_AUTHORIZATION'
139         # this is the regular location
140         authorization = request.env['HTTP_AUTHORIZATION'].to_s.split
141       end
142
143       logger.debug( "AUTH: #{authorization}" )
144
145       if authorization and authorization[0] == "Basic"
146         # logger.debug( "AUTH2: #{authorization}" )
147         login, passwd = Base64.decode64(authorization[1]).split(':')[0..1]
148
149         #set password to the empty string in case no password is transmitted in the auth string
150         passwd ||= ""
151       else
152         if @http_user.nil? and CONFIG['allow_anonymous'] and CONFIG['webui_host'] and [ request.env['REMOTE_HOST'], request.env['REMOTE_ADDR'] ].include?( CONFIG['webui_host'] ) and request.env['HTTP_USER_AGENT'].match(/^obs-webui/)
153           @http_user = User.find_by_login( "_nobody_" )
154           @user_permissions = Suse::Permission.new( @http_user )
155           return true
156         else
157           if @http_user.nil? and login
158             render_error :message => "User not yet registered", :status => 403,
159               :errorcode => "unregistered_user",
160               :details => "Please register your user via the web application #{CONFIG['webui_url']} once."
161             return false
162           end
163         end
164         logger.debug "no authentication string was sent"
165         render_error( :message => "Authentication required", :status => 401 ) and return false
166       end
167
168       # disallow empty passwords to prevent LDAP lockouts
169       if !passwd or passwd == ""
170         render_error( :message => "User '#{login}' did not provide a password", :status => 401 ) and return false
171       end
172
173       if defined?( LDAP_MODE ) && LDAP_MODE == :on
174         begin
175           require 'ldap'
176           logger.debug( "Using LDAP to find #{login}" )
177           ldap_info = User.find_with_ldap( login, passwd )
178         rescue LoadError
179           logger.debug "LDAP_MODE selected but 'ruby-ldap' module not installed."
180           ldap_info = nil # now fall through as if we'd not found a user
181         rescue Exception
182           logger.debug "#{login} not found in LDAP."
183           ldap_info = nil # now fall through as if we'd not found a user          
184         end
185
186         if not ldap_info.nil?
187           # We've found an ldap authenticated user - find or create an OBS userDB entry.
188           @http_user = User.find_by_login( login )
189           if @http_user
190             # Check for ldap updates
191             if @http_user.email != ldap_info[0]
192               @http_user.email = ldap_info[0]
193               @http_user.save
194             end
195           else
196             logger.debug( "No user found in database, creating" )
197             logger.debug( "Email: #{ldap_info[0]}" )
198             logger.debug( "Name : #{ldap_info[1]}" )
199             # Generate and store a fake pw in the OBS DB that no-one knows
200             chars = ["A".."Z","a".."z","0".."9"].collect { |r| r.to_a }.join
201             fakepw = (1..24).collect { chars[rand(chars.size)] }.pack("C*")
202             newuser = User.create(
203               :login => login,
204               :password => fakepw,
205               :password_confirmation => fakepw,
206               :email => ldap_info[0] )
207             unless newuser.errors.empty?
208               errstr = String.new
209               logger.debug("Creating User failed with: ")
210               newuser.errors.each_full do |msg|
211                 errstr = errstr+msg
212                 logger.debug(msg)
213               end
214               render_error( :message => "Cannot create ldap userid: '#{login}' on OBS<br>#{errstr}",
215                 :status => 401 )
216               @http_user=nil
217               return false
218             end
219             newuser.realname = ldap_info[1]
220             newuser.state = User.states['confirmed']
221             newuser.adminnote = "User created via LDAP"
222             user_role = Role.find_by_title("User")
223             newuser.roles << user_role
224
225             logger.debug( "saving new user..." )
226             newuser.save
227
228             @http_user = newuser
229           end
230
231           session[:rbac_user_id] = @http_user.id
232         else
233           logger.debug( "User not found with LDAP, falling back to database" )
234           @http_user = User.find_with_credentials login, passwd
235         end
236
237       else
238         @http_user = User.find_with_credentials login, passwd
239       end
240     end
241
242     if @http_user.nil?
243       render_error( :message => "Unknown user '#{login}' or invalid password", :status => 401 ) and return false
244     else
245       if @http_user.state == 5 or @http_user.state == 1
246         render_error :message => "User is registered but not yet approved.", :status => 403,
247           :errorcode => "unconfirmed_user",
248           :details => "<p>Your account is a registered account, but it is not yet approved for the OBS by admin.</p>"
249         return false
250       end
251
252       if @http_user.state == 2
253         logger.debug "USER found: #{@http_user.login}"
254         @user_permissions = Suse::Permission.new( @http_user )
255         return true
256       end
257     end
258
259     render_error :message => "User is registered but not in confirmed state.", :status => 403,
260       :errorcode => "inactive_user",
261       :details => "<p>Your account is a registered account, but it is in a not active state.</p>"
262     return false
263   end
264
265   hide_action :setup_backend  
266   def setup_backend
267     # initialize backend on every request
268     Suse::Backend.source_host = SOURCE_HOST
269     Suse::Backend.source_port = SOURCE_PORT
270   end
271
272   hide_action :add_api_version
273   def add_api_version
274     response.headers["X-Opensuse-APIVersion"] = "#{CONFIG['version']}"
275   end
276
277   hide_action :forward_from_backend
278   def forward_from_backend(path)
279
280     # apache & mod_xforward case
281     if CONFIG['x_use_xforward'] and CONFIG['x_use_xforward'] != "false"
282       logger.debug "[backend] VOLLEY(mod_xforward): #{path}"
283       headers['X-Forward'] = "http://#{SOURCE_HOST}:#{SOURCE_PORT}#{path}"
284       head(200)
285       return
286     end
287
288     # lighttpd 1.5 case
289     if CONFIG['x_rewrite_host']
290       logger.debug "[backend] VOLLEY(lighttpd): #{path}"
291       headers['X-Rewrite-URI'] = path
292       headers['X-Rewrite-Host'] = CONFIG['x_rewrite_host']
293       head(200)
294       return
295     end
296
297     logger.debug "[backend] VOLLEY: #{path}"
298     backend_http = Net::HTTP.new(SOURCE_HOST, SOURCE_PORT)
299     backend_http.read_timeout = 1000
300
301     file = Tempfile.new 'volley'
302     type = nil
303
304     opts = { :url_based_filename => true }
305     
306     backend_http.request_get(path) do |res|
307       opts[:status] = res.code
308       opts[:type] = res['Content-Type']
309       res.read_body do |segment|
310         file.write(segment)
311       end
312     end
313     opts[:length] = file.length
314     # streaming makes it very hard for test cases to verify output
315     opts[:stream] = false if Rails.env.test?
316     send_file(file.path, opts)
317     file.close
318   end
319
320   hide_action :download_request
321   def download_request
322     file = Tempfile.new 'volley'
323     b = request.body
324     buffer = String.new
325     while b.read(40960, buffer)
326       file.write(buffer)
327     end
328     file.close
329     file.open
330     file
331   end
332
333   def pass_to_backend( path = nil )
334     unless path
335       path = request.path+'?'+request.query_string
336     end
337
338     case request.method
339     when :get
340       forward_from_backend( path )
341       return
342     when :post
343       file = download_request
344       response = Suse::Backend.post( path, file )
345       file.close!
346     when :put
347       file = download_request
348       response = Suse::Backend.put( path, file )
349       file.close!
350     when :delete
351       response = Suse::Backend.delete( path )
352     end
353
354     send_data( response.body, :type => response.fetch( "content-type" ),
355       :disposition => "inline" )
356   end
357   public :pass_to_backend
358
359   def rescue_action_locally( exception )
360     rescue_action_in_public( exception )
361   end
362
363   def rescue_action_in_public( exception )
364     logger.error "rescue_action: caught #{exception.class}: #{exception.message}"
365     case exception
366     when Suse::Backend::HTTPError
367       xml = REXML::Document.new( exception.message.body )
368       http_status = xml.root.attributes['code']
369       unless xml.root.attributes.include? 'origin'
370         xml.root.add_attribute "origin", "backend"
371       end
372       xml_text = String.new
373       xml.write xml_text
374       render :text => xml_text, :status => http_status
375     when ActiveXML::Transport::NotFoundError
376       render_error :message => exception.message, :status => 404
377     when Suse::ValidationError
378       render_error :message => exception.message, :status => 400, :errorcode => 'validation_failed'
379     when InvalidHttpMethodError
380       render_error :message => exception.message, :errorcode => "invalid_http_method", :status => 400
381     when DbPackage::SaveError
382       render_error :message => "error saving package: #{exception.message}", :errorcode => "package_save_error", :status => 400
383     when DbProject::SaveError
384       render_error :message => "error saving project: #{exception.message}", :errorcode => "project_save_error", :status => 400
385     when ActionController::RoutingError, ActiveRecord::RecordNotFound
386       render_error :message => exception.message, :status => 404, :errorcode => "not_found"
387     when ActionController::UnknownAction
388       render_error :message => exception.message, :status => 403, :errorcode => "unknown_action"
389     when ActionView::MissingTemplate
390       render_error :message => exception.message, :status => 404, :errorcode => "not_found"
391     when MissingParameterError
392       render_error :status => 400, :message => exception.message, :errorcode => "missing_parameter"
393     when DbProject::CycleError
394       render_error :status => 400, :message => exception.message, :errorcode => "project_cycle"
395     else
396       if send_exception_mail?
397         ExceptionNotifier.deliver_exception_notification(exception, self, request, {})
398       end
399       render_error :message => "uncaught exception: #{exception.message}", :status => 400
400     end
401   end
402
403   def send_exception_mail?
404     return false unless ExceptionNotifier.exception_recipients
405     return !local_request? && !Rails.env.development?
406   end
407
408   def permissions
409     return @user_permissions
410   end
411
412   def user
413     return @http_user
414   end
415
416   def required_parameters(*parameters)
417     parameters.each do |parameter|
418       unless params.include? parameter.to_s
419         raise MissingParameterError, "Required Parameter #{parameter} missing"
420       end
421     end
422   end
423
424   def valid_http_methods(*methods)
425     list = methods.map {|x| x.to_s.downcase.to_s}
426     unless methods.include? request.method
427       raise InvalidHttpMethodError, "Invalid HTTP Method: #{request.method.to_s.upcase}"
428     end
429   end
430
431   def render_error( opt = {} )
432     if opt[:status]
433       if opt[:status].to_i == 401
434         response.headers["WWW-Authenticate"] = 'basic realm="API login"'
435       end
436     else
437       opt[:status] = 400
438     end
439
440     @exception = opt[:exception]
441     @details = opt[:details]
442
443     @summary = "Internal Server Error"
444     if opt[:message]
445       @summary = opt[:message]
446     elsif @exception
447       @summary = @exception.message
448     end
449
450     if opt[:errorcode]
451       @errorcode = opt[:errorcode]
452     elsif @exception
453       @errorcode = 'uncaught_exception'
454     else
455       @errorcode = 'unknown'
456     end
457
458     # if the exception was raised inside a template (-> @template.first_render != nil),
459     # the instance variables created in here will not be injected into the template
460     # object, so we have to do it manually
461     # This is commented out, since it does not work with Rails 2.3 anymore and is also not needed there
462     #    if @template.first_render
463     #      logger.debug "injecting error instance variables into template object"
464     #      %w{@summary @errorcode @exception}.each do |var|
465     #        @template.instance_variable_set var, eval(var) if @template.instance_variable_get(var).nil?
466     #      end
467     #    end
468
469     # on some occasions the status template doesn't receive the instance variables it needs
470     # unless render_to_string is called before (which is an ugly workaround but I don't have any
471     # idea where to start searching for the real problem)
472     render_to_string :template => 'status'
473
474     logger.info "errorcode '#{@errorcode}' - #{@summary}"
475     response.headers['X-Opensuse-Errorcode'] = @errorcode
476     render :template => 'status', :status => opt[:status], :layout => false
477   end
478
479   def render_ok(opt={})
480     # keep compatible to old call style
481     opt = {:details => opt} if opt.kind_of? String
482
483     @errorcode = "ok"
484     @summary = "Ok"
485     @details = opt[:details] if opt[:details]
486     @data = opt[:data] if opt[:data]
487     render :template => 'status', :status => 200, :layout => false
488   end
489
490   def backend
491     @backend ||= ActiveXML::Config.transport_for :bsrequest
492   end
493
494   def backend_get( path )
495     # TODO: check why not using SUSE:Backend::get
496     backend.direct_http( URI(path) )
497   end
498
499   def backend_put( path, data )
500     backend.direct_http( URI(path), :method => "PUT", :data => data )
501   end
502
503   def backend_post( path, data )
504     backend.set_additional_header("Content-Length", data.size.to_s())
505     response = backend.direct_http( URI(path), :method => "POST", :data => data )
506     backend.delete_additional_header("Content-Length")
507     return response
508   end
509
510   # Passes control to subroutines determined by action and a request parameter. By
511   # default the parameter assumed to contain the command is ':cmd'. Looks for a method
512   # named <action>_<command>
513   #
514   # Example:
515   #
516   # If you call dispatch_command from an action 'index' with the query parameter cmd
517   # having the value 'show', it will call the method 'index_show'
518   #
519   def dispatch_command(opt={})
520     defaults = {
521       :cmd_param => :cmd
522     }
523     opt = defaults.merge opt
524     unless params.has_key? opt[:cmd_param]
525       render_error :status => 400, :errorcode => "missing_parameter'",
526         :message => "missing parameter '#{opt[:cmd_param]}'"
527       return
528     end
529
530     cmd_handler = "#{params[:action]}_#{params[opt[:cmd_param]]}"
531     logger.debug "dispatch_command: trying to call method '#{cmd_handler}'"
532
533     if not self.respond_to? cmd_handler, true
534       render_error :status => 400, :errorcode => "unknown_command",
535         :message => "Unknown command '#{params[opt[:cmd_param]]}' for path #{request.path}"
536       return
537     end
538
539     __send__ cmd_handler
540   end
541   public :dispatch_command
542   hide_action :dispatch_command
543
544   def build_query_from_hash(hash, key_list=nil)
545     key_list ||= hash.keys
546     query = key_list.map do |key|
547       [hash[key]].flatten.map {|x| "#{key}=#{CGI.escape hash[key].to_s}"}.join("&") if hash.has_key?(key)
548     end
549
550     if query.empty?
551       return ""
552     else
553       return "?"+query.compact.join('&')
554     end
555   end
556
557   def query_parms_missing?(*list)
558     missing = Array.new
559     for param in list
560       missing << param unless params.has_key? param
561     end
562
563     if missing.length > 0
564       render_error :status => 400, :errorcode => "missing_query_parameters",
565         :message => "missing query parameters: #{missing.join ', '}"
566     end
567     return false
568   end
569
570   def min_votes_for_rating
571     MIN_VOTES_FOR_RATING
572   end
573
574   private
575   def shutup_rails
576     Rails.cache.silence!
577   end
578
579   def action_fragment_key( options )
580     # this is for customizing the path/filename of cached files (cached by the
581     # action_cache plugin). here we want to include params in the filename
582     par = params
583     par.delete 'controller'
584     par.delete 'action'
585     pairs = []
586     par.sort.each { |pair| pairs << pair.join('=') }
587     url_for( options ).split('://').last + "/"+ pairs.join(',').gsub(' ', '-')
588   end
589
590 end