diff -r 058bb3dc685f -r 0b59724cb3f2 cubicweb/web/application.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/cubicweb/web/application.py Sat Jan 16 13:48:51 2016 +0100 @@ -0,0 +1,532 @@ +# copyright 2003-2014 LOGILAB S.A. (Paris, FRANCE), all rights reserved. +# contact http://www.logilab.fr/ -- mailto:contact@logilab.fr +# +# This file is part of CubicWeb. +# +# CubicWeb is free software: you can redistribute it and/or modify it under the +# terms of the GNU Lesser General Public License as published by the Free +# Software Foundation, either version 2.1 of the License, or (at your option) +# any later version. +# +# CubicWeb is distributed in the hope that it will be useful, but WITHOUT +# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +# FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more +# details. +# +# You should have received a copy of the GNU Lesser General Public License along +# with CubicWeb. If not, see . +"""CubicWeb web client application object""" + +__docformat__ = "restructuredtext en" + +import sys +from time import clock, time +from contextlib import contextmanager +from warnings import warn +import json + +from six import text_type, binary_type +from six.moves import http_client + +from logilab.common.deprecation import deprecated + +from rql import BadRQLQuery + +from cubicweb import set_log_methods, cwvreg +from cubicweb import ( + ValidationError, Unauthorized, Forbidden, + AuthenticationError, NoSelectableObject, + CW_EVENT_MANAGER) +from cubicweb.repoapi import anonymous_cnx +from cubicweb.web import LOGGER, component, cors +from cubicweb.web import ( + StatusResponse, DirectResponse, Redirect, NotFound, LogOut, + RemoteCallFailed, InvalidSession, RequestError, PublishException) + +from cubicweb.web.request import CubicWebRequestBase + +# make session manager available through a global variable so the debug view can +# print information about web session +SESSION_MANAGER = None + + +@contextmanager +def anonymized_request(req): + orig_cnx = req.cnx + anon_cnx = anonymous_cnx(orig_cnx.session.repo) + req.set_cnx(anon_cnx) + try: + with anon_cnx: + yield req + finally: + req.set_cnx(orig_cnx) + + + +class CookieSessionHandler(object): + """a session handler using a cookie to store the session identifier""" + + def __init__(self, appli): + self.repo = appli.repo + self.vreg = appli.vreg + self.session_manager = self.vreg['sessions'].select('sessionmanager', + repo=self.repo) + global SESSION_MANAGER + SESSION_MANAGER = self.session_manager + if self.vreg.config.mode != 'test': + # don't try to reset session manager during test, this leads to + # weird failures when running multiple tests + CW_EVENT_MANAGER.bind('after-registry-reload', + self.reset_session_manager) + + def reset_session_manager(self): + data = self.session_manager.dump_data() + self.session_manager = self.vreg['sessions'].select('sessionmanager', + repo=self.repo) + self.session_manager.restore_data(data) + global SESSION_MANAGER + SESSION_MANAGER = self.session_manager + + @property + def clean_sessions_interval(self): + return self.session_manager.clean_sessions_interval + + def clean_sessions(self): + """cleanup sessions which has not been unused since a given amount of + time + """ + self.session_manager.clean_sessions() + + def session_cookie(self, req): + """return a string giving the name of the cookie used to store the + session identifier. + """ + if req.https: + return '__%s_https_session' % self.vreg.config.appid + return '__%s_session' % self.vreg.config.appid + + def get_session(self, req): + """Return a session object corresponding to credentials held by the req + + Session id is searched from : + - # form variable + - cookie + + If no session id is found, try opening a new session with credentials + found in the request. + + Raises AuthenticationError if no session can be found or created. + """ + cookie = req.get_cookie() + sessioncookie = self.session_cookie(req) + try: + sessionid = str(cookie[sessioncookie].value) + session = self.get_session_by_id(req, sessionid) + except (KeyError, InvalidSession): # no valid session cookie + session = self.open_session(req) + return session + + def get_session_by_id(self, req, sessionid): + session = self.session_manager.get_session(req, sessionid) + session.mtime = time() + return session + + def open_session(self, req): + session = self.session_manager.open_session(req) + sessioncookie = self.session_cookie(req) + secure = req.https and req.base_url().startswith('https://') + req.set_cookie(sessioncookie, session.sessionid, + maxage=None, secure=secure, httponly=True) + if not session.anonymous_session: + self.session_manager.postlogin(req, session) + return session + + def logout(self, req, goto_url): + """logout from the instance by cleaning the session and raising + `AuthenticationError` + """ + self.session_manager.close_session(req.session) + req.remove_cookie(self.session_cookie(req)) + raise LogOut(url=goto_url) + + # these are overridden by set_log_methods below + # only defining here to prevent pylint from complaining + info = warning = error = critical = exception = debug = lambda msg,*a,**kw: None + +class CubicWebPublisher(object): + """the publisher is a singleton hold by the web frontend, and is responsible + to publish HTTP request. + + The http server will call its main entry point ``application.handle_request``. + + .. automethod:: cubicweb.web.application.CubicWebPublisher.main_handle_request + + You have to provide both a repository and web-server config at + initialization. In all in one instance both config will be the same. + """ + + def __init__(self, repo, config, session_handler_fact=CookieSessionHandler): + self.info('starting web instance from %s', config.apphome) + self.repo = repo + self.vreg = repo.vreg + # get instance's schema + if not self.vreg.initialized: + config.init_cubes(self.repo.get_cubes()) + self.vreg.init_properties(self.repo.properties()) + self.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.handle_request = self.log_handle_request + self._logfile_lock = Lock() + else: + self._query_log = None + self.handle_request = self.main_handle_request + # instantiate session and url resolving helpers + self.session_handler = session_handler_fact(self) + self.set_urlresolver() + CW_EVENT_MANAGER.bind('after-registry-reload', self.set_urlresolver) + + def set_urlresolver(self): + self.url_resolver = self.vreg['components'].select('urlpublisher', + vreg=self.vreg) + + def get_session(self, req): + """Return a session object corresponding to credentials held by the req + + May raise AuthenticationError. + """ + return self.session_handler.get_session(req) + + # publish methods ######################################################### + + def log_handle_request(self, req, path): + """wrapper around _publish to log all queries executed for a given + accessed path + """ + def wrap_set_cnx(func): + def wrap_execute(cnx): + orig_execute = cnx.execute + def execute(rql, kwargs=None, build_descr=True): + tstart, cstart = time(), clock() + rset = orig_execute(rql, kwargs, build_descr=build_descr) + cnx.executed_queries.append((rql, kwargs, time() - tstart, clock() - cstart)) + return rset + return execute + def set_cnx(cnx): + func(cnx) + cnx.execute = wrap_execute(cnx) + cnx.executed_queries = [] + return set_cnx + req.set_cnx = wrap_set_cnx(req.set_cnx) + try: + return self.main_handle_request(req, path) + finally: + cnx = req.cnx + if cnx: + with self._logfile_lock: + 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') + + + + def main_handle_request(self, req, path): + """Process an http request + + Arguments are: + - a Request object + - path of the request object + + It returns the content of the http response. HTTP header and status are + set on the Request object. + """ + if not isinstance(req, CubicWebRequestBase): + warn('[3.15] Application entry point arguments are now (req, path) ' + 'not (path, req)', DeprecationWarning, 2) + req, path = path, req + if req.authmode == 'http': + # activate realm-based auth + realm = self.vreg.config['realm'] + req.set_header('WWW-Authenticate', [('Basic', {'realm' : realm })], raw=False) + content = b'' + try: + try: + session = self.get_session(req) + from cubicweb import repoapi + cnx = repoapi.Connection(session) + req.set_cnx(cnx) + except AuthenticationError: + # Keep the dummy session set at initialisation. + # such session with work to an some extend but raise an + # AuthenticationError on any database access. + import contextlib + @contextlib.contextmanager + def dummy(): + yield + cnx = dummy() + # XXX We want to clean up this approach in the future. But + # several cubes like registration or forgotten password rely on + # this principle. + + # nested try to allow LogOut to delegate logic to AuthenticationError + # handler + try: + ### Try to generate the actual request content + with cnx: + content = self.core_handle(req, path) + # Handle user log-out + except LogOut as ex: + # When authentification is handled by cookie the code that + # raised LogOut must has invalidated the cookie. We can just + # reload the original url without authentification + if self.vreg.config['auth-mode'] == 'cookie' and ex.url: + req.headers_out.setHeader('location', str(ex.url)) + if ex.status is not None: + req.status_out = http_client.SEE_OTHER + # When the authentification is handled by http we must + # explicitly ask for authentification to flush current http + # authentification information + else: + # Render "logged out" content. + # assignement to ``content`` prevent standard + # AuthenticationError code to overwrite it. + content = self.loggedout_content(req) + # let the explicitly reset http credential + raise AuthenticationError() + except Redirect as ex: + # authentication needs redirection (eg openid) + content = self.redirect_handler(req, ex) + # Wrong, absent or Reseted credential + except AuthenticationError: + # If there is an https url configured and + # the request does not use https, redirect to login form + https_url = self.vreg.config['https-url'] + if https_url and req.base_url() != https_url: + req.status_out = http_client.SEE_OTHER + req.headers_out.setHeader('location', https_url + 'login') + else: + # We assume here that in http auth mode the user *May* provide + # Authentification Credential if asked kindly. + if self.vreg.config['auth-mode'] == 'http': + req.status_out = http_client.UNAUTHORIZED + # In the other case (coky auth) we assume that there is no way + # for the user to provide them... + # XXX But WHY ? + else: + req.status_out = http_client.FORBIDDEN + # If previous error handling already generated a custom content + # do not overwrite it. This is used by LogOut Except + # XXX ensure we don't actually serve content + if not content: + content = self.need_login_content(req) + assert isinstance(content, binary_type) + return content + + + def core_handle(self, req, path): + """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 + """ + # don't log form values they may contains sensitive information + self.debug('publish "%s" (%s, form params: %s)', + path, req.session.sessionid, list(req.form)) + # remove user callbacks on a new request (except for json controllers + # to avoid callbacks being unregistered before they could be called) + tstart = clock() + commited = False + try: + ### standard processing of the request + try: + # apply CORS sanity checks + cors.process_request(req, self.vreg.config) + ctrlid, rset = self.url_resolver.process(req, path) + try: + controller = self.vreg['controllers'].select(ctrlid, req, + appli=self) + except NoSelectableObject: + raise Unauthorized(req._('not authorized')) + req.update_search_state() + result = controller.publish(rset=rset) + except cors.CORSPreflight: + # Return directly an empty 200 + req.status_out = 200 + result = b'' + except StatusResponse as ex: + warn('[3.16] StatusResponse is deprecated use req.status_out', + DeprecationWarning, stacklevel=2) + result = ex.content + req.status_out = ex.status + except Redirect as ex: + # Redirect may be raised by edit controller when everything went + # fine, so attempt to commit + result = self.redirect_handler(req, ex) + if req.cnx: + txuuid = req.cnx.commit() + commited = True + if txuuid is not None: + req.data['last_undoable_transaction'] = txuuid + ### error case + except NotFound as ex: + result = self.notfound_content(req) + req.status_out = ex.status + except ValidationError as ex: + result = self.validation_error_handler(req, ex) + except RemoteCallFailed as ex: + result = self.ajax_error_handler(req, ex) + except Unauthorized as ex: + req.data['errmsg'] = req._('You\'re not authorized to access this page. ' + 'If you think you should, please contact the site administrator.') + req.status_out = http_client.FORBIDDEN + result = self.error_handler(req, ex, tb=False) + except Forbidden as ex: + req.data['errmsg'] = req._('This action is forbidden. ' + 'If you think it should be allowed, please contact the site administrator.') + req.status_out = http_client.FORBIDDEN + result = self.error_handler(req, ex, tb=False) + except (BadRQLQuery, RequestError) as ex: + result = self.error_handler(req, ex, tb=False) + ### pass through exception + except DirectResponse: + if req.cnx: + req.cnx.commit() + raise + except (AuthenticationError, LogOut): + # the rollback is handled in the finally + raise + ### Last defense line + except BaseException as ex: + req.status_out = http_client.INTERNAL_SERVER_ERROR + result = self.error_handler(req, ex, tb=True) + finally: + if req.cnx and not commited: + try: + req.cnx.rollback() + except Exception: + pass # ignore rollback error at this point + self.add_undo_link_to_msg(req) + self.debug('query %s executed in %s sec', req.relative_path(), clock() - tstart) + return result + + # Error handlers + + def redirect_handler(self, req, ex): + """handle redirect + - comply to ex status + - set header field + - return empty content + """ + self.debug('redirecting to %s', str(ex.location)) + req.headers_out.setHeader('location', str(ex.location)) + assert 300 <= ex.status < 400 + req.status_out = ex.status + return b'' + + def validation_error_handler(self, req, ex): + ex.translate(req._) # translate messages using ui language + if '__errorurl' in req.form: + forminfo = {'error': ex, + 'values': req.form, + 'eidmap': req.data.get('eidmap', {}) + } + req.session.data[req.form['__errorurl']] = forminfo + # XXX form session key / __error_url should be differentiated: + # session key is 'url + #
[%s]' %( + req.build_url('undo', txuuid=txuuid), req._('undo')) + req.append_to_redirect_message(msg) + + def ajax_error_handler(self, req, ex): + req.set_header('content-type', 'application/json') + status = http_client.INTERNAL_SERVER_ERROR + if isinstance(ex, PublishException) and ex.status is not None: + status = ex.status + if req.status_out < 400: + # don't overwrite it if it's already set + req.status_out = status + json_dumper = getattr(ex, 'dumps', lambda : json.dumps({'reason': text_type(ex)})) + return json_dumper().encode('utf-8') + + # special case handling + + def need_login_content(self, req): + return self.vreg['views'].main_template(req, 'login') + + def loggedout_content(self, req): + return self.vreg['views'].main_template(req, 'loggedout') + + def notfound_content(self, req): + req.form['vid'] = '404' + view = self.vreg['views'].select('404', req) + template = self.main_template_id(req) + return self.vreg['views'].main_template(req, template, view=view) + + # template stuff + + def main_template_id(self, req): + template = req.form.get('__template', req.property_value('ui.main-template')) + if template not in self.vreg['views']: + template = 'main-template' + return template + + # these are overridden by set_log_methods below + # only defining here to prevent pylint from complaining + info = warning = error = critical = exception = debug = lambda msg,*a,**kw: None + +set_log_methods(CubicWebPublisher, LOGGER) +set_log_methods(CookieSessionHandler, LOGGER)