# HG changeset patch # User Goffi # Date 1510917056 -3600 # Node ID f0fc28b3bd1e6191f0611d588c4a535a7cf29d6d # Parent 8c9fdb58de5f0863765bddcbf9e1961d3d9bdb77 server: moved LiberviaPage code in its own module diff -r 8c9fdb58de5f -r f0fc28b3bd1e src/server/pages.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/server/pages.py Fri Nov 17 12:10:56 2017 +0100 @@ -0,0 +1,673 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2017 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +from twisted.web import server +from twisted.web import resource as web_resource +from twisted.web import util as web_util +from twisted.internet import defer +from twisted.python import failure + +from sat.core.i18n import _ +from sat.core import exceptions +from sat.tools.common import uri as common_uri +from sat.core.log import getLogger +log = getLogger(__name__) +from libervia.server.constants import Const as C +from libervia.server import session_iface +from libervia.server.utils import quote +import libervia + +import os.path +import urllib + + +class LiberviaPage(web_resource.Resource): + isLeaf = True # we handle subpages ourself + named_pages = {} + uri_callbacks = {} + pages_redirects = {} + + def __init__(self, host, root_dir, url, name=None, redirect=None, access=None, parse_url=None, + prepare_render=None, render=None, template=None, on_data_post=None): + """initiate LiberviaPages + + LiberviaPages are the main resources of Libervia, using easy to set python files + The arguments are the variables found in page_meta.py + @param host(Libervia): the running instance of Libervia + @param root_dir(unicode): aboslute file path of the page + @param url(unicode): relative URL to the page + this URL may not be valid, as pages may require path arguments + @param name(unicode, None): if not None, a unique name to identify the page + can then be used for e.g. redirection + "/" is not allowed in names (as it can be used to construct URL paths) + @param redirect(unicode, None): if not None, this page will be redirected. A redirected + parameter is used as in self.pageRedirect. parse_url will not be skipped + using this redirect parameter is called "full redirection" + using self.pageRedirect is called "partial redirection" (because some rendering method + can still be used, e.g. parse_url) + @param access(unicode, None): permission needed to access the page + None means public access. + Pages inherit from parent pages: e.g. if a "settings" page is restricted to admins, + and if "settings/blog" is public, it still can only be accessed by admins. + see C.PAGES_ACCESS_* for details + @param parse_url(callable, None): if set it will be called to handle the URL path + after this method, the page will be rendered if noting is left in path (request.postpath) + else a the request will be transmitted to a subpage + @param prepare_render(callable, None): if set, will be used to prepare the rendering + that often means gathering data using the bridge + @param render(callable, None): if not template is set, this method will be called and + what it returns will be rendered. + This method is mutually exclusive with template and must return a unicode string. + @param template(unicode, None): path to the template to render. + This method is mutually exclusive with render + @param on_data_post(callable, None): method to call when data is posted + None if not post is handled + on_data_post can return a string with following value: + - C.POST_NO_CONFIRM: confirm flag will not be set + """ + + web_resource.Resource.__init__(self) + self.host = host + self.root_dir = root_dir + self.url = url + self.name = name + if name is not None: + if name in self.named_pages: + raise exceptions.ConflictError(_(u'a Libervia page named "{}" already exists'.format(name))) + if u'/' in name: + raise ValueError(_(u'"/" is not allowed in page names')) + if not name: + raise ValueError(_(u"a page name can't be empty")) + self.named_pages[name] = self + if access is None: + access = C.PAGES_ACCESS_PUBLIC + if access not in (C.PAGES_ACCESS_PUBLIC, C.PAGES_ACCESS_PROFILE, C.PAGES_ACCESS_NONE): + raise NotImplementedError(_(u"{} access is not implemented yet").format(access)) + self.access = access + if redirect is not None: + # only page access and name make sense in case of full redirection + # so we check that rendering methods/values are not set + if not all(lambda x: x is not None + for x in (parse_url, prepare_render, render, template)): + raise ValueError(_(u"you can't use full page redirection with other rendering method," + u"check self.pageRedirect if you need to use them")) + self.redirect = redirect + else: + self.redirect = None + self.parse_url = parse_url + self.prepare_render = prepare_render + self.template = template + self.render_method = render + self.on_data_post = on_data_post + if access == C.PAGES_ACCESS_NONE: + # none pages just return a 404, no further check is needed + return + if template is None: + if not callable(render): + log.error(_(u"render must be implemented and callable if template is not set")) + else: + if render is not None: + log.error(_(u"render can't be used at the same time as template")) + if parse_url is not None and not callable(parse_url): + log.error(_(u"parse_url must be a callable")) + + @classmethod + def importPages(cls, host, parent=None, path=None): + """Recursively import Libervia pages""" + if path is None: + path = [] + if parent is None: + root_dir = os.path.join(os.path.dirname(libervia.__file__), C.PAGES_DIR) + parent = host + else: + root_dir = parent.root_dir + for d in os.listdir(root_dir): + dir_path = os.path.join(root_dir, d) + if not os.path.isdir(dir_path): + continue + meta_path = os.path.join(dir_path, C.PAGES_META_FILE) + if os.path.isfile(meta_path): + page_data = {} + new_path = path + [d] + # we don't want to force the presence of __init__.py + # so we use execfile instead of import. + # TODO: when moved to Python 3, __init__.py is not mandatory anymore + # so we can switch to import + execfile(meta_path, page_data) + resource = LiberviaPage( + host, + dir_path, + u'/' + u'/'.join(new_path), + name=page_data.get('name'), + redirect=page_data.get('redirect'), + access=page_data.get('access'), + parse_url=page_data.get('parse_url'), + prepare_render=page_data.get('prepare_render'), + render=page_data.get('render'), + template=page_data.get('template'), + on_data_post=page_data.get('on_data_post')) + parent.putChild(d, resource) + log.info(u"Added /{path} page".format(path=u'[...]/'.join(new_path))) + if 'uri_handlers' in page_data: + if not isinstance(page_data, dict): + log.error(_(u'uri_handlers must be a dict')) + else: + for uri_tuple, cb_name in page_data['uri_handlers'].iteritems(): + if len(uri_tuple) != 2 or not isinstance(cb_name, basestring): + log.error(_(u"invalid uri_tuple")) + continue + log.info(_(u'setting {}/{} URIs handler').format(*uri_tuple)) + try: + cb = page_data[cb_name] + except KeyError: + log.error(_(u'missing {name} method to handle {1}/{2}').format( + name = cb_name, *uri_tuple)) + continue + else: + cls.registerURI(uri_tuple, cb, new_path) + + LiberviaPage.importPages(host, resource, new_path) + + @classmethod + def registerURI(cls, uri_tuple, get_uri_cb, pre_path): + """register a URI handler + + @param uri_tuple(tuple[unicode, unicode]): type or URIs handler + type/subtype as returned by tools/common/parseXMPPUri + @param get_uri_cb(callable): method which take uri_data dict as only argument + and return path with correct arguments relative to page itself + @param pre_path(list[unicode]): prefix path to reference the handler page + """ + if uri_tuple in cls.uri_callbacks: + log.info(_(u"{}/{} URIs are already handled, replacing by the new handler").format(*uri_tuple)) + cls.uri_callbacks[uri_tuple] = {u'callback': get_uri_cb, + u'pre_path': pre_path} + + def getPagePathFromURI(self, uri): + """Retrieve page URL from xmpp: URI + + @param uri(unicode): URI with a xmpp: scheme + @return (unicode,None): absolute path (starting from root "/") to page handling the URI + None is returned if not page has been registered for this URI + """ + uri_data = common_uri.parseXMPPUri(uri) + try: + callback_data = self.uri_callbacks[uri_data['type'], uri_data.get('sub_type')] + except KeyError: + return + else: + url = os.path.join(u'/', u'/'.join(callback_data['pre_path']), callback_data['callback'](self, uri_data)) + return url + + @classmethod + def getPageByName(cls, name): + """retrieve page instance from its name + + @param name(unicode): name of the page + @return (LiberviaPage): page instance + @raise KeyError: the page doesn't exist + """ + return cls.named_pages[name] + + def getPageRedirectURL(self, request, page_name=u'login', url=None): + """generate URL for a page with redirect_url parameter set + + mainly used for login page with redirection to current page + @param request(server.Request): current HTTP request + @param page_name(unicode): name of the page to go + @param url(None, unicode): url to redirect to + None to use request path (i.e. current page) + @return (unicode): URL to use + """ + return u'{root_url}?redirect_url={redirect_url}'.format( + root_url = self.getPageByName(page_name).url, + redirect_url=urllib.quote_plus(request.uri) if url is None else url.encode('utf-8')) + + def getURL(self, *args): + """retrieve URL of the page set arguments + + *args(list[unicode]): argument to add to the URL as path elements + """ + url_args = [quote(a) for a in args] + + if self.name is not None and self.name in self.pages_redirects: + # we check for redirection + redirect_data = self.pages_redirects[self.name] + args_hash = tuple(args) + for limit in xrange(len(args)+1): + current_hash = args_hash[:limit] + if current_hash in redirect_data: + url_base = redirect_data[current_hash] + remaining = args[limit:] + remaining_url = '/'.join(remaining) + return os.path.join('/', url_base, remaining_url) + + return os.path.join(self.url, *url_args) + + def getSubPageURL(self, request, page_name, *args): + """retrieve a page in direct children and build its URL according to request + + request's current path is used as base (at current parsing point, + i.e. it's more prepath than path). + Requested page is checked in children and an absolute URL is then built + by the resulting combination. + This method is useful to construct absolute URLs for children instead of + using relative path, which may not work in subpages, and are linked to the + names of directories (i.e. relative URL will break if subdirectory is renamed + while getSubPageURL won't as long as page_name is consistent). + Also, request.path is used, keeping real path used by user, + and potential redirections. + @param request(server.Request): current HTTP request + @param page_name(unicode): name of the page to retrieve + it must be a direct children of current page + @param *args(list[unicode]): arguments to add as path elements + @return unicode: absolute URL to the sub page + """ + # we get url in the following way (splitting request.path instead of using + # request.prepath) because request.prepath may have been modified by + # redirection (if redirection args have been specified), while path reflect + # the real request + + # we ignore empty path elements (i.e. double '/' or '/' at the end) + path_elts = [p for p in request.path.split('/') if p] + + if request.postpath: + if not request.postpath[-1]: + # we remove trailing slash + request.postpath = request.postpath[:-1] + if request.postpath: + # getSubPageURL must return subpage from the point where + # the it is called, so we have to remove remanining + # path elements + path_elts = path_elts[:-len(request.postpath)] + + current_url = '/' + '/'.join(path_elts).decode('utf-8') + + for path, child in self.children.iteritems(): + try: + child_name = child.name + except AttributeError: + # LiberviaPage have a name, but maybe this is an other Resource + continue + if child_name == page_name: + return os.path.join(u'/', current_url, path, *args) + raise exceptions.NotFound(_(u'requested sub page has not been found')) + + def getChildWithDefault(self, path, request): + # we handle children ourselves + raise exceptions.InternalError(u"this method should not be used with LiberviaPage") + + def nextPath(self, request): + """get next URL path segment, and update request accordingly + + will move first segment of postpath in prepath + @param request(server.Request): current HTTP request + @return (unicode): unquoted segment + @raise IndexError: there is no segment left + """ + pathElement = request.postpath.pop(0) + request.prepath.append(pathElement) + return urllib.unquote(pathElement).decode('utf-8') + + def HTTPRedirect(self, request, url): + """redirect to an URL using HTTP redirection + + @param request(server.Request): current HTTP request + @param url(unicode): url to redirect to + """ + + web_util.redirectTo(url.encode('utf-8'), request) + request.finish() + raise failure.Failure(exceptions.CancelError(u'HTTP redirection is used')) + + def redirectOrContinue(self, request, redirect_arg=u'redirect_url'): + """helper method to redirect a page to an url given as arg + + if the arg is not present, the page will continue normal workflow + @param request(server.Request): current HTTP request + @param redirect_arg(unicode): argument to use to get redirection URL + @interrupt: redirect the page to requested URL + @interrupt pageError(C.HTTP_BAD_REQUEST): empty or non local URL is used + """ + try: + url = self.getPostedData(request, 'redirect_url') + except KeyError: + pass + else: + # a redirection is requested + if not url or url[0] != u'/': + # we only want local urls + self.pageError(request, C.HTTP_BAD_REQUEST) + else: + self.HTTPRedirect(request, url) + + def pageRedirect(self, page_path, request, skip_parse_url=True): + """redirect a page to a named page + + the workflow will continue with the workflow of the named page, + skipping named page's parse_url method if it exist. + If you want to do a HTTP redirection, use HTTPRedirect + @param page_path(unicode): path to page (elements are separated by "/"): + if path starts with a "/": + path is a full path starting from root + else: + - first element is name as registered in name variable + - following element are subpages path + e.g.: "blog" redirect to page named "blog" + "blog/atom.xml" redirect to atom.xml subpage of "blog" + "/common/blog/atom.xml" redirect to the page at the fiven full path + @param request(server.Request): current HTTP request + @param skip_parse_url(bool): if True, parse_url method on redirect page will be skipped + @raise KeyError: there is no known page with this name + """ + # FIXME: render non LiberviaPage resources + path = page_path.rstrip(u'/').split(u'/') + if not path[0]: + redirect_page = self.host.root + else: + redirect_page = self.named_pages[path[0]] + + for subpage in path[1:]: + if redirect_page is self.host.root: + redirect_page = redirect_page.children[subpage] + else: + redirect_page = redirect_page.original.children[subpage] + + redirect_page.renderPage(request, skip_parse_url=True) + raise failure.Failure(exceptions.CancelError(u'page redirection is used')) + + def pageError(self, request, code=C.HTTP_NOT_FOUND): + """generate an error page and terminate the request + + @param request(server.Request): HTTP request + @param core(int): error code to use + """ + template = u'error/' + unicode(code) + '.html' + + request.setResponseCode(code) + + rendered = self.host.renderer.render( + template, + root_path = '/templates/', + error_code = code, + **request.template_data) + + self.writeData(rendered, request) + raise failure.Failure(exceptions.CancelError(u'error page is used')) + + def writeData(self, data, request): + """write data to transport and finish the request""" + if data is None: + self.pageError(request) + request.write(data.encode('utf-8')) + request.finish() + + def _subpagesHandler(self, dummy, request): + """render subpage if suitable + + this method checks if there is still an unmanaged part of the path + and check if it corresponds to a subpage. If so, it render the subpage + else it render a NoResource. + If there is no unmanaged part of the segment, current page workflow is pursued + """ + if request.postpath: + subpage = self.nextPath(request) + try: + child = self.children[subpage] + except KeyError: + self.pageError(request) + else: + child.render(request) + raise failure.Failure(exceptions.CancelError(u'subpage page is used')) + + def _prepare_render(self, dummy, request): + return defer.maybeDeferred(self.prepare_render, self, request) + + def _render_method(self, dummy, request): + return defer.maybeDeferred(self.render_method, self, request) + + def _render_template(self, dummy, request): + template_data = request.template_data + + # if confirm variable is set in case of successfuly data post + session_data = self.host.getSessionData(request, session_iface.ISATSession) + if session_data.popPageFlag(self, C.FLAG_CONFIRM): + template_data[u'confirm'] = True + + return self.host.renderer.render( + self.template, + root_path = '/templates/', + media_path = '/' + C.MEDIA_DIR, + **template_data) + + def _renderEb(self, failure_, request): + """don't raise error on CancelError""" + failure_.trap(exceptions.CancelError) + + def _internalError(self, failure_, request): + """called if an error is not catched""" + log.error(_(u"Uncatched error for HTTP request on {url}: {msg}").format( + url = request.URLPath(), + msg = failure_)) + self.pageError(request, C.HTTP_INTERNAL_ERROR) + + def _on_data_post_redirect(self, ret, request): + """called when page's on_data_post has been done successfuly + + This will do a Post/Redirect/Get pattern. + this method redirect to the same page or to request.data['post_redirect_page'] + post_redirect_page can be either a page or a tuple with page as first item, then a list of unicode arguments to append to the url. + if post_redirect_page is not used, initial request.uri (i.e. the same page as where the data have been posted) will be used for redirection. + HTTP status code "See Other" (303) is used as it is the recommanded code in this case. + @param ret(None, unicode, iterable): on_data_post return value + see LiberviaPage.__init__ on_data_post docstring + """ + if ret is None: + ret = () + elif isinstance(ret, basestring): + ret = (ret,) + else: + ret = tuple(ret) + raise NotImplementedError(_(u'iterable in on_data_post return value is not used yet')) + session_data = self.host.getSessionData(request, session_iface.ISATSession) + request_data = self.getRData(request) + if 'post_redirect_page' in request_data: + redirect_page_data = request_data['post_redirect_page'] + if isinstance(redirect_page_data, tuple): + redirect_page = redirect_page_data[0] + redirect_page_args = redirect_page_data[1:] + redirect_uri = redirect_page.getURL(*redirect_page_args) + else: + redirect_page = redirect_page_data + redirect_uri = redirect_page.url + else: + redirect_page = self + redirect_uri = request.uri + + if not C.POST_NO_CONFIRM in ret: + session_data.setPageFlag(redirect_page, C.FLAG_CONFIRM) + request.setResponseCode(C.HTTP_SEE_OTHER) + request.setHeader("location", redirect_uri) + request.finish() + raise failure.Failure(exceptions.CancelError(u'Post/Redirect/Get is used')) + + def _on_data_post(self, dummy, request): + csrf_token = self.host.getSessionData(request, session_iface.ISATSession).csrf_token + try: + given_csrf = self.getPostedData(request, u'csrf_token') + except KeyError: + given_csrf = None + if given_csrf is None or given_csrf != csrf_token: + log.warning(_(u"invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format( + url=request.uri, + ip=request.getClientIP())) + self.pageError(request, C.HTTP_UNAUTHORIZED) + d = defer.maybeDeferred(self.on_data_post, self, request) + d.addCallback(self._on_data_post_redirect, request) + return d + + def getPostedData(self, request, keys, multiple=False): + """get data from a POST request and decode it + + @param request(server.Request): request linked to the session + @param keys(unicode, iterable[unicode]): name of the value(s) to get + unicode to get one value + iterable to get more than one + @param multiple(bool): True if multiple values are possible/expected + if False, the first value is returned + @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]): values received for this(these) key(s) + @raise KeyError: one specific key has been requested, and it is missing + """ + if isinstance(keys, basestring): + keys = [keys] + get_first = True + else: + get_first = False + + ret = [] + for key in keys: + gen = (urllib.unquote(v).decode('utf-8') for v in request.args.get(key,[])) + if multiple: + ret.append(gen) + else: + try: + ret.append(next(gen)) + except StopIteration: + raise KeyError(key) + + return ret[0] if get_first else ret + + def getAllPostedData(self, request, except_=()): + """get all posted data + + @param request(server.Request): request linked to the session + @param except_(iterable[unicode]): key of values to ignore + csrf_token will always be ignored + @return (dict[unicode, list[unicode]]): post values + """ + except_ = tuple(except_) + (u'csrf_token',) + ret = {} + for key, values in request.args.iteritems(): + key = urllib.unquote(key).decode('utf-8') + if key in except_: + continue + ret[key] = [urllib.unquote(v).decode('utf-8') for v in values] + return ret + + def getProfile(self, request): + """helper method to easily get current profile + + @return (unicode, None): current profile + None if no profile session is started + """ + sat_session = self.host.getSessionData(request, session_iface.ISATSession) + return sat_session.profile + + def getRData(self, request): + """helper method to get request data dict + + this dictionnary if for the request only, it is not saved in session + It is mainly used to pass data between pages/methods called during request workflow + @return (dict): request data + """ + try: + return request.data + except AttributeError: + request.data = {} + return request.data + + def _checkAccess(self, data, request): + """Check access according to self.access + + if access is not granted, show a HTTP_UNAUTHORIZED pageError and stop request, + else return data (so it can be inserted in deferred chain + """ + if self.access == C.PAGES_ACCESS_PUBLIC: + pass + elif self.access == C.PAGES_ACCESS_PROFILE: + profile = self.getProfile(request) + if not profile: + # no session started + if not self.host.options["allow_registration"]: + # registration not allowed, access is not granted + self.pageError(request, C.HTTP_UNAUTHORIZED) + else: + # registration allowed, we redirect to login page + login_url = self.getPageRedirectURL(request) + self.HTTPRedirect(request, login_url) + + return data + + def renderPage(self, request, skip_parse_url=False): + """Main method to handle the workflow of a LiberviaPage""" + # template_data are the variables passed to template + if not hasattr(request, 'template_data'): + session_data = self.host.getSessionData(request, session_iface.ISATSession) + csrf_token = session_data.csrf_token + request.template_data = {u'csrf_token': csrf_token} + + # XXX: here is the code which need to be executed once + # at the beginning of the request hanling + if request.postpath and not request.postpath[-1]: + # we don't differenciate URLs finishing with '/' or not + del request.postpath[-1] + + d = defer.Deferred() + d.addCallback(self._checkAccess, request) + + if self.redirect is not None: + self.pageRedirect(self.redirect, request, skip_parse_url=False) + + if self.parse_url is not None and not skip_parse_url: + d.addCallback(self.parse_url, request) + + d.addCallback(self._subpagesHandler, request) + + if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST): + # only HTTP GET and POST are handled so far + d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST)) + + if request.method == C.HTTP_METHOD_POST: + if self.on_data_post is None: + # if we don't have on_data_post, the page was not expecting POST + # so we return an error + d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST)) + else: + d.addCallback(self._on_data_post, request) + # by default, POST follow normal behaviour after on_data_post is called + # this can be changed by a redirection or other method call in on_data_post + + if self.prepare_render: + d.addCallback(self._prepare_render, request) + + if self.template: + d.addCallback(self._render_template, request) + elif self.render_method: + d.addCallback(self._render_method, request) + + d.addCallback(self.writeData, request) + d.addErrback(self._renderEb, request) + d.addErrback(self._internalError, request) + d.callback(self) + return server.NOT_DONE_YET + + def render_GET(self, request): + return self.renderPage(request) + + def render_POST(self, request): + return self.renderPage(request) diff -r 8c9fdb58de5f -r f0fc28b3bd1e src/server/server.py --- a/src/server/server.py Fri Nov 17 11:01:34 2017 +0100 +++ b/src/server/server.py Fri Nov 17 12:10:56 2017 +0100 @@ -39,7 +39,6 @@ from sat.tools import utils from sat.tools.common import regex from sat.tools.common import template -from sat.tools.common import uri as common_uri import re import glob @@ -52,6 +51,8 @@ import urllib from httplib import HTTPS_PORT import libervia +from libervia.server.pages import LiberviaPage +from libervia.server.utils import quote try: import OpenSSL @@ -68,11 +69,6 @@ DATA_DIR_DEFAULT = OPT_PARAMETERS_BOTH = OPT_PARAMETERS_CFG = coerceDataDir = None -def quote(value): - """shortcut to quote an unicode value for URL""" - return urllib.quote_plus(value.encode('utf-8')).replace('%40','@') - - class LiberviaSession(server.Session): sessionTimeout = C.SESSION_TIMEOUT @@ -1366,643 +1362,6 @@ return ("setAvatar", filepath, profile) -class LiberviaPage(web_resource.Resource): - isLeaf = True # we handle subpages ourself - named_pages = {} - uri_callbacks = {} - pages_redirects = {} - - def __init__(self, host, root_dir, url, name=None, redirect=None, access=None, parse_url=None, - prepare_render=None, render=None, template=None, on_data_post=None): - """initiate LiberviaPages - - LiberviaPages are the main resources of Libervia, using easy to set python files - The arguments are the variables found in page_meta.py - @param host(Libervia): the running instance of Libervia - @param root_dir(unicode): aboslute file path of the page - @param url(unicode): relative URL to the page - this URL may not be valid, as pages may require path arguments - @param name(unicode, None): if not None, a unique name to identify the page - can then be used for e.g. redirection - "/" is not allowed in names (as it can be used to construct URL paths) - @param redirect(unicode, None): if not None, this page will be redirected. A redirected - parameter is used as in self.pageRedirect. parse_url will not be skipped - using this redirect parameter is called "full redirection" - using self.pageRedirect is called "partial redirection" (because some rendering method - can still be used, e.g. parse_url) - @param access(unicode, None): permission needed to access the page - None means public access. - Pages inherit from parent pages: e.g. if a "settings" page is restricted to admins, - and if "settings/blog" is public, it still can only be accessed by admins. - see C.PAGES_ACCESS_* for details - @param parse_url(callable, None): if set it will be called to handle the URL path - after this method, the page will be rendered if noting is left in path (request.postpath) - else a the request will be transmitted to a subpage - @param prepare_render(callable, None): if set, will be used to prepare the rendering - that often means gathering data using the bridge - @param render(callable, None): if not template is set, this method will be called and - what it returns will be rendered. - This method is mutually exclusive with template and must return a unicode string. - @param template(unicode, None): path to the template to render. - This method is mutually exclusive with render - @param on_data_post(callable, None): method to call when data is posted - None if not post is handled - on_data_post can return a string with following value: - - C.POST_NO_CONFIRM: confirm flag will not be set - """ - - web_resource.Resource.__init__(self) - self.host = host - self.root_dir = root_dir - self.url = url - self.name = name - if name is not None: - if name in self.named_pages: - raise exceptions.ConflictError(_(u'a Libervia page named "{}" already exists'.format(name))) - if u'/' in name: - raise ValueError(_(u'"/" is not allowed in page names')) - if not name: - raise ValueError(_(u"a page name can't be empty")) - self.named_pages[name] = self - if access is None: - access = C.PAGES_ACCESS_PUBLIC - if access not in (C.PAGES_ACCESS_PUBLIC, C.PAGES_ACCESS_PROFILE, C.PAGES_ACCESS_NONE): - raise NotImplementedError(_(u"{} access is not implemented yet").format(access)) - self.access = access - if redirect is not None: - # only page access and name make sense in case of full redirection - # so we check that rendering methods/values are not set - if not all(lambda x: x is not None - for x in (parse_url, prepare_render, render, template)): - raise ValueError(_(u"you can't use full page redirection with other rendering method," - u"check self.pageRedirect if you need to use them")) - self.redirect = redirect - else: - self.redirect = None - self.parse_url = parse_url - self.prepare_render = prepare_render - self.template = template - self.render_method = render - self.on_data_post = on_data_post - if access == C.PAGES_ACCESS_NONE: - # none pages just return a 404, no further check is needed - return - if template is None: - if not callable(render): - log.error(_(u"render must be implemented and callable if template is not set")) - else: - if render is not None: - log.error(_(u"render can't be used at the same time as template")) - if parse_url is not None and not callable(parse_url): - log.error(_(u"parse_url must be a callable")) - - @classmethod - def importPages(cls, host, parent=None, path=None): - """Recursively import Libervia pages""" - if path is None: - path = [] - if parent is None: - root_dir = os.path.join(os.path.dirname(libervia.__file__), C.PAGES_DIR) - parent = host - else: - root_dir = parent.root_dir - for d in os.listdir(root_dir): - dir_path = os.path.join(root_dir, d) - if not os.path.isdir(dir_path): - continue - meta_path = os.path.join(dir_path, C.PAGES_META_FILE) - if os.path.isfile(meta_path): - page_data = {} - new_path = path + [d] - # we don't want to force the presence of __init__.py - # so we use execfile instead of import. - # TODO: when moved to Python 3, __init__.py is not mandatory anymore - # so we can switch to import - execfile(meta_path, page_data) - resource = LiberviaPage( - host, - dir_path, - u'/' + u'/'.join(new_path), - name=page_data.get('name'), - redirect=page_data.get('redirect'), - access=page_data.get('access'), - parse_url=page_data.get('parse_url'), - prepare_render=page_data.get('prepare_render'), - render=page_data.get('render'), - template=page_data.get('template'), - on_data_post=page_data.get('on_data_post')) - parent.putChild(d, resource) - log.info(u"Added /{path} page".format(path=u'[...]/'.join(new_path))) - if 'uri_handlers' in page_data: - if not isinstance(page_data, dict): - log.error(_(u'uri_handlers must be a dict')) - else: - for uri_tuple, cb_name in page_data['uri_handlers'].iteritems(): - if len(uri_tuple) != 2 or not isinstance(cb_name, basestring): - log.error(_(u"invalid uri_tuple")) - continue - log.info(_(u'setting {}/{} URIs handler').format(*uri_tuple)) - try: - cb = page_data[cb_name] - except KeyError: - log.error(_(u'missing {name} method to handle {1}/{2}').format( - name = cb_name, *uri_tuple)) - continue - else: - cls.registerURI(uri_tuple, cb, new_path) - - LiberviaPage.importPages(host, resource, new_path) - - @classmethod - def registerURI(cls, uri_tuple, get_uri_cb, pre_path): - """register a URI handler - - @param uri_tuple(tuple[unicode, unicode]): type or URIs handler - type/subtype as returned by tools/common/parseXMPPUri - @param get_uri_cb(callable): method which take uri_data dict as only argument - and return path with correct arguments relative to page itself - @param pre_path(list[unicode]): prefix path to reference the handler page - """ - if uri_tuple in cls.uri_callbacks: - log.info(_(u"{}/{} URIs are already handled, replacing by the new handler").format(*uri_tuple)) - cls.uri_callbacks[uri_tuple] = {u'callback': get_uri_cb, - u'pre_path': pre_path} - - def getPagePathFromURI(self, uri): - """Retrieve page URL from xmpp: URI - - @param uri(unicode): URI with a xmpp: scheme - @return (unicode,None): absolute path (starting from root "/") to page handling the URI - None is returned if not page has been registered for this URI - """ - uri_data = common_uri.parseXMPPUri(uri) - try: - callback_data = self.uri_callbacks[uri_data['type'], uri_data.get('sub_type')] - except KeyError: - return - else: - url = os.path.join(u'/', u'/'.join(callback_data['pre_path']), callback_data['callback'](self, uri_data)) - return url - - @classmethod - def getPageByName(cls, name): - """retrieve page instance from its name - - @param name(unicode): name of the page - @return (LiberviaPage): page instance - @raise KeyError: the page doesn't exist - """ - return cls.named_pages[name] - - def getPageRedirectURL(self, request, page_name=u'login', url=None): - """generate URL for a page with redirect_url parameter set - - mainly used for login page with redirection to current page - @param request(server.Request): current HTTP request - @param page_name(unicode): name of the page to go - @param url(None, unicode): url to redirect to - None to use request path (i.e. current page) - @return (unicode): URL to use - """ - return u'{root_url}?redirect_url={redirect_url}'.format( - root_url = self.getPageByName(page_name).url, - redirect_url=urllib.quote_plus(request.uri) if url is None else url.encode('utf-8')) - - def getURL(self, *args): - """retrieve URL of the page set arguments - - *args(list[unicode]): argument to add to the URL as path elements - """ - url_args = [quote(a) for a in args] - - if self.name is not None and self.name in self.pages_redirects: - # we check for redirection - redirect_data = self.pages_redirects[self.name] - args_hash = tuple(args) - for limit in xrange(len(args)+1): - current_hash = args_hash[:limit] - if current_hash in redirect_data: - url_base = redirect_data[current_hash] - remaining = args[limit:] - remaining_url = '/'.join(remaining) - return os.path.join('/', url_base, remaining_url) - - return os.path.join(self.url, *url_args) - - def getSubPageURL(self, request, page_name, *args): - """retrieve a page in direct children and build its URL according to request - - request's current path is used as base (at current parsing point, - i.e. it's more prepath than path). - Requested page is checked in children and an absolute URL is then built - by the resulting combination. - This method is useful to construct absolute URLs for children instead of - using relative path, which may not work in subpages, and are linked to the - names of directories (i.e. relative URL will break if subdirectory is renamed - while getSubPageURL won't as long as page_name is consistent). - Also, request.path is used, keeping real path used by user, - and potential redirections. - @param request(server.Request): current HTTP request - @param page_name(unicode): name of the page to retrieve - it must be a direct children of current page - @param *args(list[unicode]): arguments to add as path elements - @return unicode: absolute URL to the sub page - """ - # we get url in the following way (splitting request.path instead of using - # request.prepath) because request.prepath may have been modified by - # redirection (if redirection args have been specified), while path reflect - # the real request - - # we ignore empty path elements (i.e. double '/' or '/' at the end) - path_elts = [p for p in request.path.split('/') if p] - - if request.postpath: - if not request.postpath[-1]: - # we remove trailing slash - request.postpath = request.postpath[:-1] - if request.postpath: - # getSubPageURL must return subpage from the point where - # the it is called, so we have to remove remanining - # path elements - path_elts = path_elts[:-len(request.postpath)] - - current_url = '/' + '/'.join(path_elts).decode('utf-8') - - for path, child in self.children.iteritems(): - try: - child_name = child.name - except AttributeError: - # LiberviaPage have a name, but maybe this is an other Resource - continue - if child_name == page_name: - return os.path.join(u'/', current_url, path, *args) - raise exceptions.NotFound(_(u'requested sub page has not been found')) - - def getChildWithDefault(self, path, request): - # we handle children ourselves - raise exceptions.InternalError(u"this method should not be used with LiberviaPage") - - def nextPath(self, request): - """get next URL path segment, and update request accordingly - - will move first segment of postpath in prepath - @param request(server.Request): current HTTP request - @return (unicode): unquoted segment - @raise IndexError: there is no segment left - """ - pathElement = request.postpath.pop(0) - request.prepath.append(pathElement) - return urllib.unquote(pathElement).decode('utf-8') - - def HTTPRedirect(self, request, url): - """redirect to an URL using HTTP redirection - - @param request(server.Request): current HTTP request - @param url(unicode): url to redirect to - """ - - web_util.redirectTo(url.encode('utf-8'), request) - request.finish() - raise failure.Failure(exceptions.CancelError(u'HTTP redirection is used')) - - def redirectOrContinue(self, request, redirect_arg=u'redirect_url'): - """helper method to redirect a page to an url given as arg - - if the arg is not present, the page will continue normal workflow - @param request(server.Request): current HTTP request - @param redirect_arg(unicode): argument to use to get redirection URL - @interrupt: redirect the page to requested URL - @interrupt pageError(C.HTTP_BAD_REQUEST): empty or non local URL is used - """ - try: - url = self.getPostedData(request, 'redirect_url') - except KeyError: - pass - else: - # a redirection is requested - if not url or url[0] != u'/': - # we only want local urls - self.pageError(request, C.HTTP_BAD_REQUEST) - else: - self.HTTPRedirect(request, url) - - def pageRedirect(self, page_path, request, skip_parse_url=True): - """redirect a page to a named page - - the workflow will continue with the workflow of the named page, - skipping named page's parse_url method if it exist. - If you want to do a HTTP redirection, use HTTPRedirect - @param page_path(unicode): path to page (elements are separated by "/"): - if path starts with a "/": - path is a full path starting from root - else: - - first element is name as registered in name variable - - following element are subpages path - e.g.: "blog" redirect to page named "blog" - "blog/atom.xml" redirect to atom.xml subpage of "blog" - "/common/blog/atom.xml" redirect to the page at the fiven full path - @param request(server.Request): current HTTP request - @param skip_parse_url(bool): if True, parse_url method on redirect page will be skipped - @raise KeyError: there is no known page with this name - """ - # FIXME: render non LiberviaPage resources - path = page_path.rstrip(u'/').split(u'/') - if not path[0]: - redirect_page = self.host.root - else: - redirect_page = self.named_pages[path[0]] - - for subpage in path[1:]: - if redirect_page is self.host.root: - redirect_page = redirect_page.children[subpage] - else: - redirect_page = redirect_page.original.children[subpage] - - redirect_page.renderPage(request, skip_parse_url=True) - raise failure.Failure(exceptions.CancelError(u'page redirection is used')) - - def pageError(self, request, code=C.HTTP_NOT_FOUND): - """generate an error page and terminate the request - - @param request(server.Request): HTTP request - @param core(int): error code to use - """ - template = u'error/' + unicode(code) + '.html' - - request.setResponseCode(code) - - rendered = self.host.renderer.render( - template, - root_path = '/templates/', - error_code = code, - **request.template_data) - - self.writeData(rendered, request) - raise failure.Failure(exceptions.CancelError(u'error page is used')) - - def writeData(self, data, request): - """write data to transport and finish the request""" - if data is None: - self.pageError(request) - request.write(data.encode('utf-8')) - request.finish() - - def _subpagesHandler(self, dummy, request): - """render subpage if suitable - - this method checks if there is still an unmanaged part of the path - and check if it corresponds to a subpage. If so, it render the subpage - else it render a NoResource. - If there is no unmanaged part of the segment, current page workflow is pursued - """ - if request.postpath: - subpage = self.nextPath(request) - try: - child = self.children[subpage] - except KeyError: - self.pageError(request) - else: - child.render(request) - raise failure.Failure(exceptions.CancelError(u'subpage page is used')) - - def _prepare_render(self, dummy, request): - return defer.maybeDeferred(self.prepare_render, self, request) - - def _render_method(self, dummy, request): - return defer.maybeDeferred(self.render_method, self, request) - - def _render_template(self, dummy, request): - template_data = request.template_data - - # if confirm variable is set in case of successfuly data post - session_data = self.host.getSessionData(request, session_iface.ISATSession) - if session_data.popPageFlag(self, C.FLAG_CONFIRM): - template_data[u'confirm'] = True - - return self.host.renderer.render( - self.template, - root_path = '/templates/', - media_path = '/' + C.MEDIA_DIR, - **template_data) - - def _renderEb(self, failure_, request): - """don't raise error on CancelError""" - failure_.trap(exceptions.CancelError) - - def _internalError(self, failure_, request): - """called if an error is not catched""" - log.error(_(u"Uncatched error for HTTP request on {url}: {msg}").format( - url = request.URLPath(), - msg = failure_)) - self.pageError(request, C.HTTP_INTERNAL_ERROR) - - def _on_data_post_redirect(self, ret, request): - """called when page's on_data_post has been done successfuly - - This will do a Post/Redirect/Get pattern. - this method redirect to the same page or to request.data['post_redirect_page'] - post_redirect_page can be either a page or a tuple with page as first item, then a list of unicode arguments to append to the url. - if post_redirect_page is not used, initial request.uri (i.e. the same page as where the data have been posted) will be used for redirection. - HTTP status code "See Other" (303) is used as it is the recommanded code in this case. - @param ret(None, unicode, iterable): on_data_post return value - see LiberviaPage.__init__ on_data_post docstring - """ - if ret is None: - ret = () - elif isinstance(ret, basestring): - ret = (ret,) - else: - ret = tuple(ret) - raise NotImplementedError(_(u'iterable in on_data_post return value is not used yet')) - session_data = self.host.getSessionData(request, session_iface.ISATSession) - request_data = self.getRData(request) - if 'post_redirect_page' in request_data: - redirect_page_data = request_data['post_redirect_page'] - if isinstance(redirect_page_data, tuple): - redirect_page = redirect_page_data[0] - redirect_page_args = redirect_page_data[1:] - redirect_uri = redirect_page.getURL(*redirect_page_args) - else: - redirect_page = redirect_page_data - redirect_uri = redirect_page.url - else: - redirect_page = self - redirect_uri = request.uri - - if not C.POST_NO_CONFIRM in ret: - session_data.setPageFlag(redirect_page, C.FLAG_CONFIRM) - request.setResponseCode(C.HTTP_SEE_OTHER) - request.setHeader("location", redirect_uri) - request.finish() - raise failure.Failure(exceptions.CancelError(u'Post/Redirect/Get is used')) - - def _on_data_post(self, dummy, request): - csrf_token = self.host.getSessionData(request, session_iface.ISATSession).csrf_token - try: - given_csrf = self.getPostedData(request, u'csrf_token') - except KeyError: - given_csrf = None - if given_csrf is None or given_csrf != csrf_token: - log.warning(_(u"invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format( - url=request.uri, - ip=request.getClientIP())) - self.pageError(request, C.HTTP_UNAUTHORIZED) - d = defer.maybeDeferred(self.on_data_post, self, request) - d.addCallback(self._on_data_post_redirect, request) - return d - - def getPostedData(self, request, keys, multiple=False): - """get data from a POST request and decode it - - @param request(server.Request): request linked to the session - @param keys(unicode, iterable[unicode]): name of the value(s) to get - unicode to get one value - iterable to get more than one - @param multiple(bool): True if multiple values are possible/expected - if False, the first value is returned - @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]): values received for this(these) key(s) - @raise KeyError: one specific key has been requested, and it is missing - """ - if isinstance(keys, basestring): - keys = [keys] - get_first = True - else: - get_first = False - - ret = [] - for key in keys: - gen = (urllib.unquote(v).decode('utf-8') for v in request.args.get(key,[])) - if multiple: - ret.append(gen) - else: - try: - ret.append(next(gen)) - except StopIteration: - raise KeyError(key) - - return ret[0] if get_first else ret - - def getAllPostedData(self, request, except_=()): - """get all posted data - - @param request(server.Request): request linked to the session - @param except_(iterable[unicode]): key of values to ignore - csrf_token will always be ignored - @return (dict[unicode, list[unicode]]): post values - """ - except_ = tuple(except_) + (u'csrf_token',) - ret = {} - for key, values in request.args.iteritems(): - key = urllib.unquote(key).decode('utf-8') - if key in except_: - continue - ret[key] = [urllib.unquote(v).decode('utf-8') for v in values] - return ret - - def getProfile(self, request): - """helper method to easily get current profile - - @return (unicode, None): current profile - None if no profile session is started - """ - sat_session = self.host.getSessionData(request, session_iface.ISATSession) - return sat_session.profile - - def getRData(self, request): - """helper method to get request data dict - - this dictionnary if for the request only, it is not saved in session - It is mainly used to pass data between pages/methods called during request workflow - @return (dict): request data - """ - try: - return request.data - except AttributeError: - request.data = {} - return request.data - - def _checkAccess(self, data, request): - """Check access according to self.access - - if access is not granted, show a HTTP_UNAUTHORIZED pageError and stop request, - else return data (so it can be inserted in deferred chain - """ - if self.access == C.PAGES_ACCESS_PUBLIC: - pass - elif self.access == C.PAGES_ACCESS_PROFILE: - profile = self.getProfile(request) - if not profile: - # no session started - if not self.host.options["allow_registration"]: - # registration not allowed, access is not granted - self.pageError(request, C.HTTP_UNAUTHORIZED) - else: - # registration allowed, we redirect to login page - login_url = self.getPageRedirectURL(request) - self.HTTPRedirect(request, login_url) - - return data - - def renderPage(self, request, skip_parse_url=False): - """Main method to handle the workflow of a LiberviaPage""" - # template_data are the variables passed to template - if not hasattr(request, 'template_data'): - session_data = self.host.getSessionData(request, session_iface.ISATSession) - csrf_token = session_data.csrf_token - request.template_data = {u'csrf_token': csrf_token} - - # XXX: here is the code which need to be executed once - # at the beginning of the request hanling - if request.postpath and not request.postpath[-1]: - # we don't differenciate URLs finishing with '/' or not - del request.postpath[-1] - - d = defer.Deferred() - d.addCallback(self._checkAccess, request) - - if self.redirect is not None: - self.pageRedirect(self.redirect, request, skip_parse_url=False) - - if self.parse_url is not None and not skip_parse_url: - d.addCallback(self.parse_url, request) - - d.addCallback(self._subpagesHandler, request) - - if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST): - # only HTTP GET and POST are handled so far - d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST)) - - if request.method == C.HTTP_METHOD_POST: - if self.on_data_post is None: - # if we don't have on_data_post, the page was not expecting POST - # so we return an error - d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST)) - else: - d.addCallback(self._on_data_post, request) - # by default, POST follow normal behaviour after on_data_post is called - # this can be changed by a redirection or other method call in on_data_post - - if self.prepare_render: - d.addCallback(self._prepare_render, request) - - if self.template: - d.addCallback(self._render_template, request) - elif self.render_method: - d.addCallback(self._render_method, request) - - d.addCallback(self.writeData, request) - d.addErrback(self._renderEb, request) - d.addErrback(self._internalError, request) - d.callback(self) - return server.NOT_DONE_YET - - def render_GET(self, request): - return self.renderPage(request) - - def render_POST(self, request): - return self.renderPage(request) - - class Libervia(service.Service): def __init__(self, options): diff -r 8c9fdb58de5f -r f0fc28b3bd1e src/server/utils.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/server/utils.py Fri Nov 17 12:10:56 2017 +0100 @@ -0,0 +1,24 @@ +#!/usr/bin/python +# -*- coding: utf-8 -*- + +# Libervia: a Salut à Toi frontend +# Copyright (C) 2011-2017 Jérôme Poisson + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program 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 Affero General Public License for more details. + +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . +import urllib + + +def quote(value): + """shortcut to quote an unicode value for URL""" + return urllib.quote_plus(value.encode('utf-8')).replace('%40','@')