diff -r 000000000000 -r b97547f5f1fa web/application.py --- /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 + + 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)