web/application.py
changeset 11057 0b59724cb3f2
parent 11052 058bb3dc685f
child 11058 23eb30449fe5
equal deleted inserted replaced
11052:058bb3dc685f 11057:0b59724cb3f2
     1 # copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
       
     2 # contact http://www.logilab.fr/ -- mailto:contact@logilab.fr
       
     3 #
       
     4 # This file is part of CubicWeb.
       
     5 #
       
     6 # CubicWeb is free software: you can redistribute it and/or modify it under the
       
     7 # terms of the GNU Lesser General Public License as published by the Free
       
     8 # Software Foundation, either version 2.1 of the License, or (at your option)
       
     9 # any later version.
       
    10 #
       
    11 # CubicWeb is distributed in the hope that it will be useful, but WITHOUT
       
    12 # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
       
    13 # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
       
    14 # details.
       
    15 #
       
    16 # You should have received a copy of the GNU Lesser General Public License along
       
    17 # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
       
    18 """CubicWeb web client application object"""
       
    19 
       
    20 __docformat__ = "restructuredtext en"
       
    21 
       
    22 import sys
       
    23 from time import clock, time
       
    24 from contextlib import contextmanager
       
    25 from warnings import warn
       
    26 import json
       
    27 
       
    28 from six import text_type, binary_type
       
    29 from six.moves import http_client
       
    30 
       
    31 from logilab.common.deprecation import deprecated
       
    32 
       
    33 from rql import BadRQLQuery
       
    34 
       
    35 from cubicweb import set_log_methods, cwvreg
       
    36 from cubicweb import (
       
    37     ValidationError, Unauthorized, Forbidden,
       
    38     AuthenticationError, NoSelectableObject,
       
    39     CW_EVENT_MANAGER)
       
    40 from cubicweb.repoapi import anonymous_cnx
       
    41 from cubicweb.web import LOGGER, component, cors
       
    42 from cubicweb.web import (
       
    43     StatusResponse, DirectResponse, Redirect, NotFound, LogOut,
       
    44     RemoteCallFailed, InvalidSession, RequestError, PublishException)
       
    45 
       
    46 from cubicweb.web.request import CubicWebRequestBase
       
    47 
       
    48 # make session manager available through a global variable so the debug view can
       
    49 # print information about web session
       
    50 SESSION_MANAGER = None
       
    51 
       
    52 
       
    53 @contextmanager
       
    54 def anonymized_request(req):
       
    55     orig_cnx = req.cnx
       
    56     anon_cnx = anonymous_cnx(orig_cnx.session.repo)
       
    57     req.set_cnx(anon_cnx)
       
    58     try:
       
    59         with anon_cnx:
       
    60             yield req
       
    61     finally:
       
    62         req.set_cnx(orig_cnx)
       
    63 
       
    64 
       
    65 
       
    66 class CookieSessionHandler(object):
       
    67     """a session handler using a cookie to store the session identifier"""
       
    68 
       
    69     def __init__(self, appli):
       
    70         self.repo = appli.repo
       
    71         self.vreg = appli.vreg
       
    72         self.session_manager = self.vreg['sessions'].select('sessionmanager',
       
    73                                                             repo=self.repo)
       
    74         global SESSION_MANAGER
       
    75         SESSION_MANAGER = self.session_manager
       
    76         if self.vreg.config.mode != 'test':
       
    77             # don't try to reset session manager during test, this leads to
       
    78             # weird failures when running multiple tests
       
    79             CW_EVENT_MANAGER.bind('after-registry-reload',
       
    80                                   self.reset_session_manager)
       
    81 
       
    82     def reset_session_manager(self):
       
    83         data = self.session_manager.dump_data()
       
    84         self.session_manager = self.vreg['sessions'].select('sessionmanager',
       
    85                                                             repo=self.repo)
       
    86         self.session_manager.restore_data(data)
       
    87         global SESSION_MANAGER
       
    88         SESSION_MANAGER = self.session_manager
       
    89 
       
    90     @property
       
    91     def clean_sessions_interval(self):
       
    92         return self.session_manager.clean_sessions_interval
       
    93 
       
    94     def clean_sessions(self):
       
    95         """cleanup sessions which has not been unused since a given amount of
       
    96         time
       
    97         """
       
    98         self.session_manager.clean_sessions()
       
    99 
       
   100     def session_cookie(self, req):
       
   101         """return a string giving the name of the cookie used to store the
       
   102         session identifier.
       
   103         """
       
   104         if req.https:
       
   105             return '__%s_https_session' % self.vreg.config.appid
       
   106         return '__%s_session' % self.vreg.config.appid
       
   107 
       
   108     def get_session(self, req):
       
   109         """Return a session object corresponding to credentials held by the req
       
   110 
       
   111         Session id is searched from :
       
   112         - # form variable
       
   113         - cookie
       
   114 
       
   115         If no session id is found, try opening a new session with credentials
       
   116         found in the request.
       
   117 
       
   118         Raises AuthenticationError if no session can be found or created.
       
   119         """
       
   120         cookie = req.get_cookie()
       
   121         sessioncookie = self.session_cookie(req)
       
   122         try:
       
   123             sessionid = str(cookie[sessioncookie].value)
       
   124             session = self.get_session_by_id(req, sessionid)
       
   125         except (KeyError, InvalidSession): # no valid session cookie
       
   126             session = self.open_session(req)
       
   127         return session
       
   128 
       
   129     def get_session_by_id(self, req, sessionid):
       
   130         session = self.session_manager.get_session(req, sessionid)
       
   131         session.mtime = time()
       
   132         return session
       
   133 
       
   134     def open_session(self, req):
       
   135         session = self.session_manager.open_session(req)
       
   136         sessioncookie = self.session_cookie(req)
       
   137         secure = req.https and req.base_url().startswith('https://')
       
   138         req.set_cookie(sessioncookie, session.sessionid,
       
   139                        maxage=None, secure=secure, httponly=True)
       
   140         if not session.anonymous_session:
       
   141             self.session_manager.postlogin(req, session)
       
   142         return session
       
   143 
       
   144     def logout(self, req, goto_url):
       
   145         """logout from the instance by cleaning the session and raising
       
   146         `AuthenticationError`
       
   147         """
       
   148         self.session_manager.close_session(req.session)
       
   149         req.remove_cookie(self.session_cookie(req))
       
   150         raise LogOut(url=goto_url)
       
   151 
       
   152     # these are overridden by set_log_methods below
       
   153     # only defining here to prevent pylint from complaining
       
   154     info = warning = error = critical = exception = debug = lambda msg,*a,**kw: None
       
   155 
       
   156 class CubicWebPublisher(object):
       
   157     """the publisher is a singleton hold by the web frontend, and is responsible
       
   158     to publish HTTP request.
       
   159 
       
   160     The http server will call its main entry point ``application.handle_request``.
       
   161 
       
   162     .. automethod:: cubicweb.web.application.CubicWebPublisher.main_handle_request
       
   163 
       
   164     You have to provide both a repository and web-server config at
       
   165     initialization. In all in one instance both config will be the same.
       
   166     """
       
   167 
       
   168     def __init__(self, repo, config, session_handler_fact=CookieSessionHandler):
       
   169         self.info('starting web instance from %s', config.apphome)
       
   170         self.repo = repo
       
   171         self.vreg = repo.vreg
       
   172         # get instance's schema
       
   173         if not self.vreg.initialized:
       
   174             config.init_cubes(self.repo.get_cubes())
       
   175             self.vreg.init_properties(self.repo.properties())
       
   176             self.vreg.set_schema(self.repo.get_schema())
       
   177         # set the correct publish method
       
   178         if config['query-log-file']:
       
   179             from threading import Lock
       
   180             self._query_log = open(config['query-log-file'], 'a')
       
   181             self.handle_request = self.log_handle_request
       
   182             self._logfile_lock = Lock()
       
   183         else:
       
   184             self._query_log = None
       
   185             self.handle_request = self.main_handle_request
       
   186         # instantiate session and url resolving helpers
       
   187         self.session_handler = session_handler_fact(self)
       
   188         self.set_urlresolver()
       
   189         CW_EVENT_MANAGER.bind('after-registry-reload', self.set_urlresolver)
       
   190 
       
   191     def set_urlresolver(self):
       
   192         self.url_resolver = self.vreg['components'].select('urlpublisher',
       
   193                                                            vreg=self.vreg)
       
   194 
       
   195     def get_session(self, req):
       
   196         """Return a session object corresponding to credentials held by the req
       
   197 
       
   198         May raise AuthenticationError.
       
   199         """
       
   200         return self.session_handler.get_session(req)
       
   201 
       
   202     # publish methods #########################################################
       
   203 
       
   204     def log_handle_request(self, req, path):
       
   205         """wrapper around _publish to log all queries executed for a given
       
   206         accessed path
       
   207         """
       
   208         def wrap_set_cnx(func):
       
   209             def wrap_execute(cnx):
       
   210                 orig_execute = cnx.execute
       
   211                 def execute(rql, kwargs=None, build_descr=True):
       
   212                     tstart, cstart = time(), clock()
       
   213                     rset = orig_execute(rql, kwargs, build_descr=build_descr)
       
   214                     cnx.executed_queries.append((rql, kwargs, time() - tstart, clock() - cstart))
       
   215                     return rset
       
   216                 return execute
       
   217             def set_cnx(cnx):
       
   218                 func(cnx)
       
   219                 cnx.execute = wrap_execute(cnx)
       
   220                 cnx.executed_queries = []
       
   221             return set_cnx
       
   222         req.set_cnx = wrap_set_cnx(req.set_cnx)
       
   223         try:
       
   224             return self.main_handle_request(req, path)
       
   225         finally:
       
   226             cnx = req.cnx
       
   227             if cnx:
       
   228                 with self._logfile_lock:
       
   229                     try:
       
   230                         result = ['\n'+'*'*80]
       
   231                         result.append(req.url())
       
   232                         result += ['%s %s -- (%.3f sec, %.3f CPU sec)' % q
       
   233                                    for q in cnx.executed_queries]
       
   234                         cnx.executed_queries = []
       
   235                         self._query_log.write('\n'.join(result).encode(req.encoding))
       
   236                         self._query_log.flush()
       
   237                     except Exception:
       
   238                         self.exception('error while logging queries')
       
   239 
       
   240 
       
   241 
       
   242     def main_handle_request(self, req, path):
       
   243         """Process an http request
       
   244 
       
   245         Arguments are:
       
   246         - a Request object
       
   247         - path of the request object
       
   248 
       
   249         It returns the content of the http response. HTTP header and status are
       
   250         set on the Request object.
       
   251         """
       
   252         if not isinstance(req, CubicWebRequestBase):
       
   253             warn('[3.15] Application entry point arguments are now (req, path) '
       
   254                  'not (path, req)', DeprecationWarning, 2)
       
   255             req, path = path, req
       
   256         if req.authmode == 'http':
       
   257             # activate realm-based auth
       
   258             realm = self.vreg.config['realm']
       
   259             req.set_header('WWW-Authenticate', [('Basic', {'realm' : realm })], raw=False)
       
   260         content = b''
       
   261         try:
       
   262             try:
       
   263                 session = self.get_session(req)
       
   264                 from  cubicweb import repoapi
       
   265                 cnx = repoapi.Connection(session)
       
   266                 req.set_cnx(cnx)
       
   267             except AuthenticationError:
       
   268                 # Keep the dummy session set at initialisation.
       
   269                 # such session with work to an some extend but raise an
       
   270                 # AuthenticationError on any database access.
       
   271                 import contextlib
       
   272                 @contextlib.contextmanager
       
   273                 def dummy():
       
   274                     yield
       
   275                 cnx = dummy()
       
   276                 # XXX We want to clean up this approach in the future. But
       
   277                 # several cubes like registration or forgotten password rely on
       
   278                 # this principle.
       
   279 
       
   280             # nested try to allow LogOut to delegate logic to AuthenticationError
       
   281             # handler
       
   282             try:
       
   283                 ### Try to generate the actual request content
       
   284                 with cnx:
       
   285                     content = self.core_handle(req, path)
       
   286             # Handle user log-out
       
   287             except LogOut as ex:
       
   288                 # When authentification is handled by cookie the code that
       
   289                 # raised LogOut must has invalidated the cookie. We can just
       
   290                 # reload the original url without authentification
       
   291                 if self.vreg.config['auth-mode'] == 'cookie' and ex.url:
       
   292                     req.headers_out.setHeader('location', str(ex.url))
       
   293                 if ex.status is not None:
       
   294                     req.status_out = http_client.SEE_OTHER
       
   295                 # When the authentification is handled by http we must
       
   296                 # explicitly ask for authentification to flush current http
       
   297                 # authentification information
       
   298                 else:
       
   299                     # Render "logged out" content.
       
   300                     # assignement to ``content`` prevent standard
       
   301                     # AuthenticationError code to overwrite it.
       
   302                     content = self.loggedout_content(req)
       
   303                     # let the explicitly reset http credential
       
   304                     raise AuthenticationError()
       
   305         except Redirect as ex:
       
   306             # authentication needs redirection (eg openid)
       
   307             content = self.redirect_handler(req, ex)
       
   308         # Wrong, absent or Reseted credential
       
   309         except AuthenticationError:
       
   310             # If there is an https url configured and
       
   311             # the request does not use https, redirect to login form
       
   312             https_url = self.vreg.config['https-url']
       
   313             if https_url and req.base_url() != https_url:
       
   314                 req.status_out = http_client.SEE_OTHER
       
   315                 req.headers_out.setHeader('location', https_url + 'login')
       
   316             else:
       
   317                 # We assume here that in http auth mode the user *May* provide
       
   318                 # Authentification Credential if asked kindly.
       
   319                 if self.vreg.config['auth-mode'] == 'http':
       
   320                     req.status_out = http_client.UNAUTHORIZED
       
   321                 # In the other case (coky auth) we assume that there is no way
       
   322                 # for the user to provide them...
       
   323                 # XXX But WHY ?
       
   324                 else:
       
   325                     req.status_out = http_client.FORBIDDEN
       
   326                 # If previous error handling already generated a custom content
       
   327                 # do not overwrite it. This is used by LogOut Except
       
   328                 # XXX ensure we don't actually serve content
       
   329                 if not content:
       
   330                     content = self.need_login_content(req)
       
   331         assert isinstance(content, binary_type)
       
   332         return content
       
   333 
       
   334 
       
   335     def core_handle(self, req, path):
       
   336         """method called by the main publisher to process <path>
       
   337 
       
   338         should return a string containing the resulting page or raise a
       
   339         `NotFound` exception
       
   340 
       
   341         :type path: str
       
   342         :param path: the path part of the url to publish
       
   343 
       
   344         :type req: `web.Request`
       
   345         :param req: the request object
       
   346 
       
   347         :rtype: str
       
   348         :return: the result of the pusblished url
       
   349         """
       
   350         # don't log form values they may contains sensitive information
       
   351         self.debug('publish "%s" (%s, form params: %s)',
       
   352                    path, req.session.sessionid, list(req.form))
       
   353         # remove user callbacks on a new request (except for json controllers
       
   354         # to avoid callbacks being unregistered before they could be called)
       
   355         tstart = clock()
       
   356         commited = False
       
   357         try:
       
   358             ### standard processing of the request
       
   359             try:
       
   360                 # apply CORS sanity checks
       
   361                 cors.process_request(req, self.vreg.config)
       
   362                 ctrlid, rset = self.url_resolver.process(req, path)
       
   363                 try:
       
   364                     controller = self.vreg['controllers'].select(ctrlid, req,
       
   365                                                                  appli=self)
       
   366                 except NoSelectableObject:
       
   367                     raise Unauthorized(req._('not authorized'))
       
   368                 req.update_search_state()
       
   369                 result = controller.publish(rset=rset)
       
   370             except cors.CORSPreflight:
       
   371                 # Return directly an empty 200
       
   372                 req.status_out = 200
       
   373                 result = b''
       
   374             except StatusResponse as ex:
       
   375                 warn('[3.16] StatusResponse is deprecated use req.status_out',
       
   376                      DeprecationWarning, stacklevel=2)
       
   377                 result = ex.content
       
   378                 req.status_out = ex.status
       
   379             except Redirect as ex:
       
   380                 # Redirect may be raised by edit controller when everything went
       
   381                 # fine, so attempt to commit
       
   382                 result = self.redirect_handler(req, ex)
       
   383             if req.cnx:
       
   384                 txuuid = req.cnx.commit()
       
   385                 commited = True
       
   386                 if txuuid is not None:
       
   387                     req.data['last_undoable_transaction'] = txuuid
       
   388         ### error case
       
   389         except NotFound as ex:
       
   390             result = self.notfound_content(req)
       
   391             req.status_out = ex.status
       
   392         except ValidationError as ex:
       
   393             result = self.validation_error_handler(req, ex)
       
   394         except RemoteCallFailed as ex:
       
   395             result = self.ajax_error_handler(req, ex)
       
   396         except Unauthorized as ex:
       
   397             req.data['errmsg'] = req._('You\'re not authorized to access this page. '
       
   398                                        'If you think you should, please contact the site administrator.')
       
   399             req.status_out = http_client.FORBIDDEN
       
   400             result = self.error_handler(req, ex, tb=False)
       
   401         except Forbidden as ex:
       
   402             req.data['errmsg'] = req._('This action is forbidden. '
       
   403                                        'If you think it should be allowed, please contact the site administrator.')
       
   404             req.status_out = http_client.FORBIDDEN
       
   405             result = self.error_handler(req, ex, tb=False)
       
   406         except (BadRQLQuery, RequestError) as ex:
       
   407             result = self.error_handler(req, ex, tb=False)
       
   408         ### pass through exception
       
   409         except DirectResponse:
       
   410             if req.cnx:
       
   411                 req.cnx.commit()
       
   412             raise
       
   413         except (AuthenticationError, LogOut):
       
   414             # the rollback is handled in the finally
       
   415             raise
       
   416         ### Last defense line
       
   417         except BaseException as ex:
       
   418             req.status_out = http_client.INTERNAL_SERVER_ERROR
       
   419             result = self.error_handler(req, ex, tb=True)
       
   420         finally:
       
   421             if req.cnx and not commited:
       
   422                 try:
       
   423                     req.cnx.rollback()
       
   424                 except Exception:
       
   425                     pass # ignore rollback error at this point
       
   426         self.add_undo_link_to_msg(req)
       
   427         self.debug('query %s executed in %s sec', req.relative_path(), clock() - tstart)
       
   428         return result
       
   429 
       
   430     # Error handlers
       
   431 
       
   432     def redirect_handler(self, req, ex):
       
   433         """handle redirect
       
   434         - comply to ex status
       
   435         - set header field
       
   436         - return empty content
       
   437         """
       
   438         self.debug('redirecting to %s', str(ex.location))
       
   439         req.headers_out.setHeader('location', str(ex.location))
       
   440         assert 300 <= ex.status < 400
       
   441         req.status_out = ex.status
       
   442         return b''
       
   443 
       
   444     def validation_error_handler(self, req, ex):
       
   445         ex.translate(req._) # translate messages using ui language
       
   446         if '__errorurl' in req.form:
       
   447             forminfo = {'error': ex,
       
   448                         'values': req.form,
       
   449                         'eidmap': req.data.get('eidmap', {})
       
   450                         }
       
   451             req.session.data[req.form['__errorurl']] = forminfo
       
   452             # XXX form session key / __error_url should be differentiated:
       
   453             # session key is 'url + #<form dom id', though we usually don't want
       
   454             # the browser to move to the form since it hides the global
       
   455             # messages.
       
   456             location = req.form['__errorurl'].rsplit('#', 1)[0]
       
   457             req.headers_out.setHeader('location', str(location))
       
   458             req.status_out = http_client.SEE_OTHER
       
   459             return b''
       
   460         req.status_out = http_client.CONFLICT
       
   461         return self.error_handler(req, ex, tb=False)
       
   462 
       
   463     def error_handler(self, req, ex, tb=False):
       
   464         excinfo = sys.exc_info()
       
   465         if tb:
       
   466             self.exception(repr(ex))
       
   467         req.set_header('Cache-Control', 'no-cache')
       
   468         req.remove_header('Etag')
       
   469         req.remove_header('Content-disposition')
       
   470         req.reset_message()
       
   471         req.reset_headers()
       
   472         if req.ajax_request:
       
   473             return self.ajax_error_handler(req, ex)
       
   474         try:
       
   475             req.data['ex'] = ex
       
   476             if tb:
       
   477                 req.data['excinfo'] = excinfo
       
   478             errview = self.vreg['views'].select('error', req)
       
   479             template = self.main_template_id(req)
       
   480             content = self.vreg['views'].main_template(req, template, view=errview)
       
   481         except Exception:
       
   482             content = self.vreg['views'].main_template(req, 'error-template')
       
   483         if isinstance(ex, PublishException) and ex.status is not None:
       
   484             req.status_out = ex.status
       
   485         return content
       
   486 
       
   487     def add_undo_link_to_msg(self, req):
       
   488         txuuid = req.data.get('last_undoable_transaction')
       
   489         if txuuid is not None:
       
   490             msg = u'<span class="undo">[<a href="%s">%s</a>]</span>' %(
       
   491             req.build_url('undo', txuuid=txuuid), req._('undo'))
       
   492             req.append_to_redirect_message(msg)
       
   493 
       
   494     def ajax_error_handler(self, req, ex):
       
   495         req.set_header('content-type', 'application/json')
       
   496         status = http_client.INTERNAL_SERVER_ERROR
       
   497         if isinstance(ex, PublishException) and ex.status is not None:
       
   498             status = ex.status
       
   499         if req.status_out < 400:
       
   500             # don't overwrite it if it's already set
       
   501             req.status_out = status
       
   502         json_dumper = getattr(ex, 'dumps', lambda : json.dumps({'reason': text_type(ex)}))
       
   503         return json_dumper().encode('utf-8')
       
   504 
       
   505     # special case handling
       
   506 
       
   507     def need_login_content(self, req):
       
   508         return self.vreg['views'].main_template(req, 'login')
       
   509 
       
   510     def loggedout_content(self, req):
       
   511         return self.vreg['views'].main_template(req, 'loggedout')
       
   512 
       
   513     def notfound_content(self, req):
       
   514         req.form['vid'] = '404'
       
   515         view = self.vreg['views'].select('404', req)
       
   516         template = self.main_template_id(req)
       
   517         return self.vreg['views'].main_template(req, template, view=view)
       
   518 
       
   519     # template stuff
       
   520 
       
   521     def main_template_id(self, req):
       
   522         template = req.form.get('__template', req.property_value('ui.main-template'))
       
   523         if template not in self.vreg['views']:
       
   524             template = 'main-template'
       
   525         return template
       
   526 
       
   527     # these are overridden by set_log_methods below
       
   528     # only defining here to prevent pylint from complaining
       
   529     info = warning = error = critical = exception = debug = lambda msg,*a,**kw: None
       
   530 
       
   531 set_log_methods(CubicWebPublisher, LOGGER)
       
   532 set_log_methods(CookieSessionHandler, LOGGER)