Mercurial > libervia-web
diff src/server/pages.py @ 984:f0fc28b3bd1e
server: moved LiberviaPage code in its own module
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 17 Nov 2017 12:10:56 +0100 |
parents | src/server/server.py@bcacf970f970 |
children | 64826e69f365 |
line wrap: on
line diff
--- /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 <goffi@goffi.org> + +# 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 <http://www.gnu.org/licenses/>. +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)