web/application.py
changeset 0 b97547f5f1fa
child 168 f6be0c92fc38
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/web/application.py	Wed Nov 05 15:52:50 2008 +0100
@@ -0,0 +1,404 @@
+"""CubicWeb web client application object
+
+:organization: Logilab
+:copyright: 2001-2008 LOGILAB S.A. (Paris, FRANCE), all rights reserved.
+:contact: http://www.logilab.fr/ -- mailto:contact@logilab.fr
+"""
+__docformat__ = "restructuredtext en"
+
+import sys
+from time import clock, time
+
+from rql import BadRQLQuery
+
+from cubicweb import set_log_methods
+from cubicweb import (ValidationError, Unauthorized, AuthenticationError,
+                   NoSelectableObject, RepositoryError)
+from cubicweb.cwconfig import CubicWebConfiguration
+from cubicweb.cwvreg import CubicWebRegistry
+from cubicweb.web import (LOGGER, StatusResponse, DirectResponse, Redirect, NotFound,
+                       RemoteCallFailed, ExplicitLogin, InvalidSession)
+from cubicweb.web.component import SingletonComponent
+
+# make session manager available through a global variable so the debug view can
+# print information about web session
+SESSION_MANAGER = None
+
+class AbstractSessionManager(SingletonComponent):
+    """manage session data associated to a session identifier"""
+    id = 'sessionmanager'
+    
+    def __init__(self):
+        self.session_time = self.vreg.config['http-session-time'] or None
+        assert self.session_time is None or self.session_time > 0
+        self.cleanup_session_time = self.vreg.config['cleanup-session-time'] or 120
+        assert self.cleanup_session_time > 0
+        self.cleanup_anon_session_time = self.vreg.config['cleanup-anonymous-session-time'] or 720
+        assert self.cleanup_anon_session_time > 0
+        if self.session_time:
+            assert self.cleanup_session_time < self.session_time
+            assert self.cleanup_anon_session_time < self.session_time
+        self.authmanager = self.vreg.select_component('authmanager')
+        assert self.authmanager, 'no authentication manager found'
+        
+    def clean_sessions(self):
+        """cleanup sessions which has not been unused since a given amount of
+        time. Return the number of sessions which have been closed.
+        """
+        self.debug('cleaning http sessions')
+        closed, total = 0, 0
+        for session in self.current_sessions():
+            no_use_time = (time() - session.last_usage_time)
+            total += 1
+            if session.anonymous_connection:
+                if no_use_time >= self.cleanup_anon_session_time:
+                    self.close_session(session)
+                    closed += 1
+            elif no_use_time >= self.cleanup_session_time:
+                self.close_session(session)
+                closed += 1
+        return closed, total - closed
+    
+    def has_expired(self, session):
+        """return True if the web session associated to the session is expired
+        """
+        return not (self.session_time is None or
+                    time() < session.last_usage_time + self.session_time)
+                
+    def current_sessions(self):
+        """return currently open sessions"""
+        raise NotImplementedError()
+            
+    def get_session(self, req, sessionid):
+        """return existing session for the given session identifier"""
+        raise NotImplementedError()
+
+    def open_session(self, req):
+        """open and return a new session for the given request
+        
+        :raise ExplicitLogin: if authentication is required
+        """
+        raise NotImplementedError()
+    
+    def close_session(self, session):
+        """close session on logout or on invalid session detected (expired out,
+        corrupted...)
+        """
+        raise NotImplementedError()
+
+
+class AbstractAuthenticationManager(SingletonComponent):
+    """authenticate user associated to a request and check session validity"""
+    id = 'authmanager'
+
+    def authenticate(self, req):
+        """authenticate user and return corresponding user object
+        
+        :raise ExplicitLogin: if authentication is required (no authentication
+        info found or wrong user/password)
+        """
+        raise NotImplementedError()
+
+    
+class CookieSessionHandler(object):
+    """a session handler using a cookie to store the session identifier
+
+    :cvar SESSION_VAR:
+      string giving the name of the variable used to store the session
+      identifier
+    """
+    SESSION_VAR = '__session'
+    
+    def __init__(self, appli):
+        self.session_manager = appli.vreg.select_component('sessionmanager')
+        assert self.session_manager, 'no session manager found'
+        global SESSION_MANAGER
+        SESSION_MANAGER = self.session_manager
+        if not 'last_login_time' in appli.vreg.schema:
+            self._update_last_login_time = lambda x: None
+
+    def clean_sessions(self):
+        """cleanup sessions which has not been unused since a given amount of
+        time
+        """
+        self.session_manager.clean_sessions()
+        
+    def set_session(self, req):
+        """associate a session to the request
+
+        Session id is searched from :
+        - # form variable
+        - cookie
+
+        if no session id is found, open a new session for the connected user
+        or request authentification as needed
+
+        :raise Redirect: if authentication has occured and succeed        
+        """
+        assert req.cnx is None # at this point no cnx should be set on the request
+        cookie = req.get_cookie()
+        try:
+            sessionid = str(cookie[self.SESSION_VAR].value)
+        except KeyError: # no session cookie
+            session = self.open_session(req)
+        else:
+            try:
+                session = self.get_session(req, sessionid)
+            except InvalidSession:
+                try:
+                    session = self.open_session(req)
+                except ExplicitLogin:
+                    req.remove_cookie(cookie, self.SESSION_VAR)
+                    raise
+        # remember last usage time for web session tracking
+        session.last_usage_time = time()
+
+    def get_session(self, req, sessionid):
+        return self.session_manager.get_session(req, sessionid)
+    
+    def open_session(self, req):
+        session = self.session_manager.open_session(req)
+        cookie = req.get_cookie()
+        cookie[self.SESSION_VAR] = session.sessionid
+        req.set_cookie(cookie, self.SESSION_VAR, maxage=None)
+        # remember last usage time for web session tracking
+        session.last_usage_time = time()
+        if not session.anonymous_connection:
+            self._postlogin(req)
+        return session
+
+    def _update_last_login_time(self, req):
+        try:
+            req.execute('SET X last_login_time NOW WHERE X eid %(x)s',
+                        {'x' : req.user.eid}, 'x')
+            req.cnx.commit()
+        except (RepositoryError, Unauthorized):
+            # ldap user are not writeable for instance
+            req.cnx.rollback()
+        except:
+            req.cnx.rollback()
+            raise
+        
+    def _postlogin(self, req):
+        """postlogin: the user has been authenticated, redirect to the original
+        page (index by default) with a welcome message
+        """
+        # Update last connection date
+        # XXX: this should be in a post login hook in the repository, but there
+        #      we can't differentiate actual login of automatic session
+        #      reopening. Is it actually a problem?
+        self._update_last_login_time(req)
+        args = req.form
+        args['__message'] = req._('welcome %s !') % req.user.login
+        if 'vid' in req.form:
+            args['vid'] = req.form['vid']
+        if 'rql' in req.form:
+            args['rql'] = req.form['rql']
+        path = req.relative_path(False)
+        if path == 'login':
+            path = 'view'
+        raise Redirect(req.build_url(path, **args))
+    
+    def logout(self, req):
+        """logout from the application by cleaning the session and raising
+        `AuthenticationError`
+        """
+        self.session_manager.close_session(req.cnx)
+        req.remove_cookie(req.get_cookie(), self.SESSION_VAR)
+        raise AuthenticationError()
+
+
+class CubicWebPublisher(object):
+    """Central registry for the web application. This is one of the central
+    object in the web application, coupling dynamically loaded objects with
+    the application's schema and the application's configuration objects.
+    
+    It specializes the VRegistry by adding some convenience methods to
+    access to stored objects. Currently we have the following registries
+    of objects known by the web application (library may use some others
+    additional registries):
+    * controllers, which are directly plugged into the application
+      object to handle request publishing
+    * views
+    * templates
+    * components
+    * actions
+    """
+    
+    def __init__(self, config, debug=None,
+                 session_handler_fact=CookieSessionHandler,
+                 vreg=None):
+        super(CubicWebPublisher, self).__init__()
+        # connect to the repository and get application's schema
+        if vreg is None:
+            vreg = CubicWebRegistry(config, debug=debug)
+        self.vreg = vreg
+        self.info('starting web application from %s', config.apphome)
+        self.repo = config.repository(vreg)
+        if not vreg.initialized:
+            self.config.init_cubes(self.repo.get_cubes())
+            vreg.init_properties(self.repo.properties())
+        vreg.set_schema(self.repo.get_schema())
+        # set the correct publish method
+        if config['query-log-file']:
+            from threading import Lock
+            self._query_log = open(config['query-log-file'], 'a')
+            self.publish = self.log_publish
+            self._logfile_lock = Lock()            
+        else:
+            self._query_log = None
+            self.publish = self.main_publish
+        # instantiate session and url resolving helpers
+        self.session_handler = session_handler_fact(self)
+        self.url_resolver = vreg.select_component('urlpublisher')
+    
+    def connect(self, req):
+        """return a connection for a logged user object according to existing
+        sessions (i.e. a new connection may be created or an already existing
+        one may be reused
+        """
+        self.session_handler.set_session(req)
+
+    def select_controller(self, oid, req):
+        """return the most specific view according to the resultset"""
+        vreg = self.vreg
+        try:
+            return vreg.select(vreg.registry_objects('controllers', oid),
+                               req=req, appli=self)
+        except NoSelectableObject:
+            raise Unauthorized(req._('not authorized'))
+            
+    # publish methods #########################################################
+        
+    def log_publish(self, path, req):
+        """wrapper around _publish to log all queries executed for a given
+        accessed path
+        """
+        try:
+            return self.main_publish(path, req)
+        finally:
+            cnx = req.cnx
+            self._logfile_lock.acquire()
+            try:
+                try:
+                    result = ['\n'+'*'*80]
+                    result.append(req.url())
+                    result += ['%s %s -- (%.3f sec, %.3f CPU sec)' % q for q in cnx.executed_queries]
+                    cnx.executed_queries = []
+                    self._query_log.write('\n'.join(result).encode(req.encoding))
+                    self._query_log.flush()
+                except Exception:
+                    self.exception('error while logging queries')
+            finally:
+                self._logfile_lock.release()
+
+    def main_publish(self, path, req):
+        """method called by the main publisher to process <path>
+        
+        should return a string containing the resulting page or raise a
+        `NotFound` exception
+
+        :type path: str
+        :param path: the path part of the url to publish
+        
+        :type req: `web.Request`
+        :param req: the request object
+
+        :rtype: str
+        :return: the result of the pusblished url
+        """
+        path = path or 'view'
+        # don't log form values they may contains sensitive information
+        self.info('publish "%s" (form params: %s)', path, req.form.keys())
+        # remove user callbacks on a new request (except for json controllers
+        # to avoid callbacks being unregistered before they could be called)
+        tstart = clock()
+        try:
+            try:
+                ctrlid, rset = self.url_resolver.process(req, path)
+                controller = self.select_controller(ctrlid, req)
+                result = controller.publish(rset=rset)
+                if req.cnx is not None:
+                    # req.cnx is None if anonymous aren't allowed and we are
+                    # displaying the cookie authentication form
+                    req.cnx.commit()
+            except (StatusResponse, DirectResponse):
+                req.cnx.commit()
+                raise
+            except Redirect:
+                # redirect is raised by edit controller when everything went fine,
+                # so try to commit
+                try:
+                    req.cnx.commit()
+                except ValidationError, ex:
+                    self.validation_error_handler(req, ex)
+                except Unauthorized, ex:
+                    req.data['errmsg'] = req._('You\'re not authorized to access this page. '
+                                               'If you think you should, please contact the site administrator.')
+                    self.error_handler(req, ex, tb=False)
+                except Exception, ex:
+                    self.error_handler(req, ex, tb=True)
+                else:
+                    # delete validation errors which may have been previously set
+                    if '__errorurl' in req.form:
+                        req.del_session_data(req.form['__errorurl'])
+                    raise
+            except (AuthenticationError, NotFound, RemoteCallFailed):
+                raise
+            except ValidationError, ex:
+                self.validation_error_handler(req, ex)
+            except (Unauthorized, BadRQLQuery), ex:
+                self.error_handler(req, ex, tb=False)
+            except Exception, ex:
+                self.error_handler(req, ex, tb=True)
+        finally:
+            if req.cnx is not None:
+                try:
+                    req.cnx.rollback()
+                except:
+                    pass # ignore rollback error at this point
+        self.info('query %s executed in %s sec', req.relative_path(), clock() - tstart)
+        return result
+
+    def validation_error_handler(self, req, ex):
+        ex.errors = dict((k, v) for k, v in ex.errors.items())
+        if '__errorurl' in req.form:
+            forminfo = {'errors': ex,
+                        'values': req.form,
+                        'eidmap': req.data.get('eidmap', {})
+                        }
+            req.set_session_data(req.form['__errorurl'], forminfo)
+            raise Redirect(req.form['__errorurl'])
+        self.error_handler(req, ex, tb=False)
+        
+    def error_handler(self, req, ex, tb=False):
+        excinfo = sys.exc_info()
+        self.exception(repr(ex))
+        req.set_header('Cache-Control', 'no-cache')
+        req.remove_header('Etag')
+        req.message = None
+        req.reset_headers()
+        try:
+            req.data['ex'] = ex
+            if tb:
+                req.data['excinfo'] = excinfo
+            req.form['vid'] = 'error'
+            content = self.vreg.main_template(req, 'main')
+        except:
+            content = self.vreg.main_template(req, 'error')
+        raise StatusResponse(500, content)
+    
+    def need_login_content(self, req):
+        return self.vreg.main_template(req, 'login')
+    
+    def loggedout_content(self, req):
+        return self.vreg.main_template(req, 'loggedout')
+    
+    def notfound_content(self, req):
+        template = req.property_value('ui.main-template') or 'main'
+        req.form['vid'] = '404'
+        return self.vreg.main_template(req, template)
+
+
+set_log_methods(CubicWebPublisher, LOGGER)
+set_log_methods(CookieSessionHandler, LOGGER)