comparison libervia/server/pages.py @ 1124:28e3eb3bb217

files reorganisation and installation rework: - files have been reorganised to follow other SàT projects and usual Python organisation (no more "/src" directory) - VERSION file is now used, as for other SàT projects - replace the overcomplicated setup.py be a more sane one. Pyjamas part is not compiled anymore by setup.py, it must be done separatly - removed check for data_dir if it's empty - installation tested working in virtual env - libervia launching script is now in bin/libervia
author Goffi <goffi@goffi.org>
date Sat, 25 Aug 2018 17:59:48 +0200
parents src/server/pages.py@cdd389ef97bc
children 9234f29053b0
comparison
equal deleted inserted replaced
1123:63a4b8fe9782 1124:28e3eb3bb217
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # Libervia: a Salut à Toi frontend
5 # Copyright (C) 2011-2018 Jérôme Poisson <goffi@goffi.org>
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19 from twisted.web import server
20 from twisted.web import resource as web_resource
21 from twisted.web import util as web_util
22 from twisted.internet import defer
23 from twisted.words.protocols.jabber import jid
24 from twisted.python import failure
25
26 from sat.core.i18n import _
27 from sat.core import exceptions
28 from sat.tools.common import uri as common_uri
29 from sat.tools.common import date_utils
30 from sat.core.log import getLogger
31
32 log = getLogger(__name__)
33 from libervia.server.constants import Const as C
34 from libervia.server import session_iface
35 from libervia.server.utils import quote, SubPage
36 import libervia
37
38 from collections import namedtuple
39 import uuid
40 import os.path
41 import urllib
42 import time
43 import hashlib
44
45 WebsocketMeta = namedtuple("WebsocketMeta", ("url", "token", "debug"))
46
47
48 class CacheBase(object):
49 def __init__(self):
50 self._created = time.time()
51 self._last_access = self._created
52
53 @property
54 def created(self):
55 return self._created
56
57 @property
58 def last_access(self):
59 return self._last_access
60
61 @last_access.setter
62 def last_access(self, timestamp):
63 self._last_access = timestamp
64
65
66 class CachePage(CacheBase):
67 def __init__(self, rendered):
68 super(CachePage, self).__init__()
69 self._created = time.time()
70 self._last_access = self._created
71 self._rendered = rendered
72 self._hash = hashlib.sha256(rendered).hexdigest()
73
74 @property
75 def rendered(self):
76 return self._rendered
77
78 @property
79 def hash(self):
80 return self._hash
81
82
83 class CacheURL(CacheBase):
84 def __init__(self, request):
85 super(CacheURL, self).__init__()
86 try:
87 self._data = request.data.copy()
88 except AttributeError:
89 self._data = {}
90 self._template_data = request.template_data.copy()
91 self._prepath = request.prepath[:]
92 self._postpath = request.postpath[:]
93 del self._template_data["csrf_token"]
94
95 def use(self, request):
96 self.last_access = time.time()
97 request.data = self._data.copy()
98 request.template_data.update(self._template_data)
99 request.prepath = self._prepath[:]
100 request.postpath = self._postpath[:]
101
102
103 class LiberviaPage(web_resource.Resource):
104 isLeaf = True #  we handle subpages ourself
105 named_pages = {}
106 uri_callbacks = {}
107 signals_handlers = {}
108 pages_redirects = {}
109 cache = {}
110 cached_urls = {}
111 #  Set of tuples (service/node/sub_id) of nodes subscribed for caching
112 # sub_id can be empty string if not handled by service
113 cache_pubsub_sub = set()
114 main_menu = None
115
116 def __init__(
117 self,
118 host,
119 root_dir,
120 url,
121 name=None,
122 redirect=None,
123 access=None,
124 dynamic=False,
125 parse_url=None,
126 prepare_render=None,
127 render=None,
128 template=None,
129 on_data_post=None,
130 on_data=None,
131 on_signal=None,
132 url_cache=False,
133 ):
134 """initiate LiberviaPages
135
136 LiberviaPages are the main resources of Libervia, using easy to set python files
137 The arguments are the variables found in page_meta.py
138 @param host(Libervia): the running instance of Libervia
139 @param root_dir(unicode): aboslute file path of the page
140 @param url(unicode): relative URL to the page
141 this URL may not be valid, as pages may require path arguments
142 @param name(unicode, None): if not None, a unique name to identify the page
143 can then be used for e.g. redirection
144 "/" is not allowed in names (as it can be used to construct URL paths)
145 @param redirect(unicode, None): if not None, this page will be redirected. A redirected
146 parameter is used as in self.pageRedirect. parse_url will not be skipped
147 using this redirect parameter is called "full redirection"
148 using self.pageRedirect is called "partial redirection" (because some rendering method
149 can still be used, e.g. parse_url)
150 @param access(unicode, None): permission needed to access the page
151 None means public access.
152 Pages inherit from parent pages: e.g. if a "settings" page is restricted to admins,
153 and if "settings/blog" is public, it still can only be accessed by admins.
154 see C.PAGES_ACCESS_* for details
155 @param dynamic(bool): if True, activate websocket for bidirectional communication
156 @param parse_url(callable, None): if set it will be called to handle the URL path
157 after this method, the page will be rendered if noting is left in path (request.postpath)
158 else a the request will be transmitted to a subpage
159 @param prepare_render(callable, None): if set, will be used to prepare the rendering
160 that often means gathering data using the bridge
161 @param render(callable, None): if not template is set, this method will be called and
162 what it returns will be rendered.
163 This method is mutually exclusive with template and must return a unicode string.
164 @param template(unicode, None): path to the template to render.
165 This method is mutually exclusive with render
166 @param on_data_post(callable, None): method to call when data is posted
167 None if not post is handled
168 on_data_post can return a string with following value:
169 - C.POST_NO_CONFIRM: confirm flag will not be set
170 @param on_data(callable, None): method to call when dynamic data is sent
171 this method is used with Libervia's websocket mechanism
172 @param on_signal(callable, None): method to call when a registered signal is received
173 this method is used with Libervia's websocket mechanism
174 """
175
176 web_resource.Resource.__init__(self)
177 self.host = host
178 self.root_dir = root_dir
179 self.url = url
180 self.name = name
181 if name is not None:
182 if name in self.named_pages:
183 raise exceptions.ConflictError(
184 _(u'a Libervia page named "{}" already exists'.format(name))
185 )
186 if u"/" in name:
187 raise ValueError(_(u'"/" is not allowed in page names'))
188 if not name:
189 raise ValueError(_(u"a page name can't be empty"))
190 self.named_pages[name] = self
191 if access is None:
192 access = C.PAGES_ACCESS_PUBLIC
193 if access not in (
194 C.PAGES_ACCESS_PUBLIC,
195 C.PAGES_ACCESS_PROFILE,
196 C.PAGES_ACCESS_NONE,
197 ):
198 raise NotImplementedError(
199 _(u"{} access is not implemented yet").format(access)
200 )
201 self.access = access
202 self.dynamic = dynamic
203 if redirect is not None:
204 # only page access and name make sense in case of full redirection
205 # so we check that rendering methods/values are not set
206 if not all(
207 lambda x: x is not None
208 for x in (parse_url, prepare_render, render, template)
209 ):
210 raise ValueError(
211 _(
212 u"you can't use full page redirection with other rendering method,"
213 u"check self.pageRedirect if you need to use them"
214 )
215 )
216 self.redirect = redirect
217 else:
218 self.redirect = None
219 self.parse_url = parse_url
220 self.prepare_render = prepare_render
221 self.template = template
222 self.render_method = render
223 self.on_data_post = on_data_post
224 self.on_data = on_data
225 self.on_signal = on_signal
226 self.url_cache = url_cache
227 if access == C.PAGES_ACCESS_NONE:
228 # none pages just return a 404, no further check is needed
229 return
230 if template is not None and render is not None:
231 log.error(_(u"render and template methods can't be used at the same time"))
232 if parse_url is not None and not callable(parse_url):
233 log.error(_(u"parse_url must be a callable"))
234
235 # if not None, next rendering will be cached
236 #  it must then contain a list of the the keys to use (without the page instance)
237 # e.g. [C.SERVICE_PROFILE, "pubsub", server@example.tld, pubsub_node]
238 self._do_cache = None
239
240 def __unicode__(self):
241 return u"LiberviaPage {name} at {url}".format(
242 name=self.name or u"<anonymous>", url=self.url
243 )
244
245 def __str__(self):
246 return self.__unicode__().encode("utf-8")
247
248 @classmethod
249 def importPages(cls, host, parent=None, path=None):
250 """Recursively import Libervia pages"""
251 if path is None:
252 path = []
253 if parent is None:
254 root_dir = os.path.join(os.path.dirname(libervia.__file__), C.PAGES_DIR)
255 parent = host
256 else:
257 root_dir = parent.root_dir
258 for d in os.listdir(root_dir):
259 dir_path = os.path.join(root_dir, d)
260 if not os.path.isdir(dir_path):
261 continue
262 meta_path = os.path.join(dir_path, C.PAGES_META_FILE)
263 if os.path.isfile(meta_path):
264 page_data = {}
265 new_path = path + [d]
266 # we don't want to force the presence of __init__.py
267 # so we use execfile instead of import.
268 # TODO: when moved to Python 3, __init__.py is not mandatory anymore
269 # so we can switch to import
270 execfile(meta_path, page_data)
271 resource = LiberviaPage(
272 host,
273 dir_path,
274 u"/" + u"/".join(new_path),
275 name=page_data.get("name"),
276 redirect=page_data.get("redirect"),
277 access=page_data.get("access"),
278 dynamic=page_data.get("dynamic", False),
279 parse_url=page_data.get("parse_url"),
280 prepare_render=page_data.get("prepare_render"),
281 render=page_data.get("render"),
282 template=page_data.get("template"),
283 on_data_post=page_data.get("on_data_post"),
284 on_data=page_data.get("on_data"),
285 on_signal=page_data.get("on_signal"),
286 url_cache=page_data.get("url_cache", False),
287 )
288 parent.putChild(d, resource)
289 log.info(u"Added /{path} page".format(path=u"[...]/".join(new_path)))
290 if "uri_handlers" in page_data:
291 if not isinstance(page_data, dict):
292 log.error(_(u"uri_handlers must be a dict"))
293 else:
294 for uri_tuple, cb_name in page_data["uri_handlers"].iteritems():
295 if len(uri_tuple) != 2 or not isinstance(cb_name, basestring):
296 log.error(_(u"invalid uri_tuple"))
297 continue
298 log.info(_(u"setting {}/{} URIs handler").format(*uri_tuple))
299 try:
300 cb = page_data[cb_name]
301 except KeyError:
302 log.error(
303 _(u"missing {name} method to handle {1}/{2}").format(
304 name=cb_name, *uri_tuple
305 )
306 )
307 continue
308 else:
309 resource.registerURI(uri_tuple, cb)
310
311 LiberviaPage.importPages(host, resource, new_path)
312
313 @classmethod
314 def setMenu(cls, menus):
315 main_menu = []
316 for menu in menus:
317 if not menu:
318 msg = _(u"menu item can't be empty")
319 log.error(msg)
320 raise ValueError(msg)
321 elif isinstance(menu, list):
322 if len(menu) != 2:
323 msg = _(
324 u"menu item as list must be in the form [page_name, absolue URL]"
325 )
326 log.error(msg)
327 raise ValueError(msg)
328 page_name, url = menu
329 else:
330 page_name = menu
331 try:
332 url = cls.getPageByName(page_name).url
333 except KeyError as e:
334 log.error(
335 _(
336 u"Can'find a named page ({msg}), please check menu_json in configuration."
337 ).format(msg=e)
338 )
339 raise e
340 main_menu.append((page_name, url))
341 cls.main_menu = main_menu
342
343 def registerURI(self, uri_tuple, get_uri_cb):
344 """register a URI handler
345
346 @param uri_tuple(tuple[unicode, unicode]): type or URIs handler
347 type/subtype as returned by tools/common/parseXMPPUri
348 or type/None to handle all subtypes
349 @param get_uri_cb(callable): method which take uri_data dict as only argument
350 and return absolute path with correct arguments or None if the page
351 can't handle this URL
352 """
353 if uri_tuple in self.uri_callbacks:
354 log.info(
355 _(u"{}/{} URIs are already handled, replacing by the new handler").format(
356 *uri_tuple
357 )
358 )
359 self.uri_callbacks[uri_tuple] = (self, get_uri_cb)
360
361 def registerSignal(self, request, signal, check_profile=True):
362 r"""register a signal handler
363
364 the page must be dynamic
365 when signal is received, self.on_signal will be called with:
366 - request
367 - signal name
368 - signal arguments
369 signal handler will be removed when connection with dynamic page will be lost
370 @param signal(unicode): name of the signal
371 last arg of signal must be profile, as it will be checked to filter signals
372 @param check_profile(bool): if True, signal profile (which MUST be last arg) will be
373 checked against session profile.
374 /!\ if False, profile will not be checked/filtered, be sure to know what you are doing
375 if you unset this option /!\
376 """
377 # FIXME: add a timeout, if socket is not opened before it, signal handler must be removed
378 if not self.dynamic:
379 log.error(_(u"You can't register signal if page is not dynamic"))
380 return
381 LiberviaPage.signals_handlers.setdefault(signal, {})[id(request)] = (
382 self,
383 request,
384 check_profile,
385 )
386 request._signals_registered.append(signal)
387
388 @classmethod
389 def getPagePathFromURI(cls, uri):
390 """Retrieve page URL from xmpp: URI
391
392 @param uri(unicode): URI with a xmpp: scheme
393 @return (unicode,None): absolute path (starting from root "/") to page handling the URI
394 None is returned if no page has been registered for this URI
395 """
396 uri_data = common_uri.parseXMPPUri(uri)
397 try:
398 page, cb = cls.uri_callbacks[uri_data["type"], uri_data["sub_type"]]
399 except KeyError:
400 url = None
401 else:
402 url = cb(page, uri_data)
403 if url is None:
404 # no handler found
405 # we try to find a more generic one
406 try:
407 page, cb = cls.uri_callbacks[uri_data["type"], None]
408 except KeyError:
409 pass
410 else:
411 url = cb(page, uri_data)
412 return url
413
414 @classmethod
415 def getPageByName(cls, name):
416 """retrieve page instance from its name
417
418 @param name(unicode): name of the page
419 @return (LiberviaPage): page instance
420 @raise KeyError: the page doesn't exist
421 """
422 return cls.named_pages[name]
423
424 def getPageRedirectURL(self, request, page_name=u"login", url=None):
425 """generate URL for a page with redirect_url parameter set
426
427 mainly used for login page with redirection to current page
428 @param request(server.Request): current HTTP request
429 @param page_name(unicode): name of the page to go
430 @param url(None, unicode): url to redirect to
431 None to use request path (i.e. current page)
432 @return (unicode): URL to use
433 """
434 return u"{root_url}?redirect_url={redirect_url}".format(
435 root_url=self.getPageByName(page_name).url,
436 redirect_url=urllib.quote_plus(request.uri)
437 if url is None
438 else url.encode("utf-8"),
439 )
440
441 def getURL(self, *args):
442 """retrieve URL of the page set arguments
443
444 *args(list[unicode]): argument to add to the URL as path elements
445 empty or None arguments will be ignored
446 """
447 url_args = [quote(a) for a in args if a]
448
449 if self.name is not None and self.name in self.pages_redirects:
450 #  we check for redirection
451 redirect_data = self.pages_redirects[self.name]
452 args_hash = tuple(args)
453 for limit in xrange(len(args) + 1):
454 current_hash = args_hash[:limit]
455 if current_hash in redirect_data:
456 url_base = redirect_data[current_hash]
457 remaining = args[limit:]
458 remaining_url = "/".join(remaining)
459 return os.path.join("/", url_base, remaining_url)
460
461 return os.path.join(self.url, *url_args)
462
463 def getCurrentURL(self, request):
464 """retrieve URL used to access this page
465
466 @return(unicode): current URL
467 """
468 # we get url in the following way (splitting request.path instead of using
469 # request.prepath) because request.prepath may have been modified by
470 # redirection (if redirection args have been specified), while path reflect
471 # the real request
472
473 # we ignore empty path elements (i.e. double '/' or '/' at the end)
474 path_elts = [p for p in request.path.split("/") if p]
475
476 if request.postpath:
477 if not request.postpath[-1]:
478 #  we remove trailing slash
479 request.postpath = request.postpath[:-1]
480 if request.postpath:
481 #  getSubPageURL must return subpage from the point where
482 # the it is called, so we have to remove remanining
483 # path elements
484 path_elts = path_elts[: -len(request.postpath)]
485
486 return u"/" + "/".join(path_elts).decode("utf-8")
487
488 def getParamURL(self, request, **kwargs):
489 """use URL of current request but modify the parameters in query part
490
491 **kwargs(dict[str, unicode]): argument to use as query parameters
492 @return (unicode): constructed URL
493 """
494 current_url = self.getCurrentURL(request)
495 if kwargs:
496 encoded = urllib.urlencode(
497 {k: v.encode("utf-8") for k, v in kwargs.iteritems()}
498 ).decode("utf-8")
499 current_url = current_url + u"?" + encoded
500 return current_url
501
502 def getSubPageByName(self, subpage_name, parent=None):
503 """retrieve a subpage and its path using its name
504
505 @param subpage_name(unicode): name of the sub page
506 it must be a direct children of parent page
507 @param parent(LiberviaPage, None): parent page
508 None to use current page
509 @return (tuple[str, LiberviaPage]): page subpath and instance
510 @raise exceptions.NotFound: no page has been found
511 """
512 if parent is None:
513 parent = self
514 for path, child in parent.children.iteritems():
515 try:
516 child_name = child.name
517 except AttributeError:
518 #  LiberviaPages have a name, but maybe this is an other Resource
519 continue
520 if child_name == subpage_name:
521 return path, child
522 raise exceptions.NotFound(_(u"requested sub page has not been found"))
523
524 def getSubPageURL(self, request, page_name, *args):
525 """retrieve a page in direct children and build its URL according to request
526
527 request's current path is used as base (at current parsing point,
528 i.e. it's more prepath than path).
529 Requested page is checked in children and an absolute URL is then built
530 by the resulting combination.
531 This method is useful to construct absolute URLs for children instead of
532 using relative path, which may not work in subpages, and are linked to the
533 names of directories (i.e. relative URL will break if subdirectory is renamed
534 while getSubPageURL won't as long as page_name is consistent).
535 Also, request.path is used, keeping real path used by user,
536 and potential redirections.
537 @param request(server.Request): current HTTP request
538 @param page_name(unicode): name of the page to retrieve
539 it must be a direct children of current page
540 @param *args(list[unicode]): arguments to add as path elements
541 if an arg is None, it will be ignored
542 @return (unicode): absolute URL to the sub page
543 """
544 current_url = self.getCurrentURL(request)
545 path, child = self.getSubPageByName(page_name)
546 return os.path.join(
547 u"/", current_url, path, *[quote(a) for a in args if a is not None]
548 )
549
550 def getURLByNames(self, named_path):
551 """retrieve URL from pages names and arguments
552
553 @param named_path(list[tuple[unicode, list[unicode]]]): path to the page as a list
554 of tuples of 2 items:
555 - first item is page name
556 - second item is list of path arguments of this page
557 @return (unicode): URL to the requested page with given path arguments
558 @raise exceptions.NotFound: one of the page was not found
559 """
560 current_page = None
561 path = []
562 for page_name, page_args in named_path:
563 if current_page is None:
564 current_page = self.getPageByName(page_name)
565 path.append(current_page.getURL(*page_args))
566 else:
567 sub_path, current_page = self.getSubPageByName(
568 page_name, parent=current_page
569 )
570 path.append(sub_path)
571 if page_args:
572 path.extend([quote(a) for a in page_args])
573 return self.host.checkRedirection(u"/".join(path))
574
575 def getURLByPath(self, *args):
576 """generate URL by path
577
578 this method as a similar effect as getURLByNames, but it is more readable
579 by using SubPage to get pages instead of using tuples
580 @param *args: path element:
581 - if unicode, will be used as argument
582 - if util.SubPage instance, must be the name of a subpage
583 @return (unicode): generated path
584 """
585 args = list(args)
586 if not args:
587 raise ValueError("You must specify path elements")
588 # root page is the one needed to construct the base of the URL
589 # if first arg is not a SubPage instance, we use current page
590 if not isinstance(args[0], SubPage):
591 root = self
592 else:
593 root = self.getPageByName(args.pop(0))
594 # we keep track of current page to check subpage
595 current_page = root
596 url_elts = []
597 arguments = []
598 while True:
599 while args and not isinstance(args[0], SubPage):
600 arguments.append(quote(args.pop(0)))
601 if not url_elts:
602 url_elts.append(root.getURL(*arguments))
603 else:
604 url_elts.extend(arguments)
605 if not args:
606 break
607 else:
608 path, current_page = current_page.getSubPageByName(args.pop(0))
609 arguments = [path]
610 return self.host.checkRedirection(u"/".join(url_elts))
611
612 def getChildWithDefault(self, path, request):
613 # we handle children ourselves
614 raise exceptions.InternalError(
615 u"this method should not be used with LiberviaPage"
616 )
617
618 def nextPath(self, request):
619 """get next URL path segment, and update request accordingly
620
621 will move first segment of postpath in prepath
622 @param request(server.Request): current HTTP request
623 @return (unicode): unquoted segment
624 @raise IndexError: there is no segment left
625 """
626 pathElement = request.postpath.pop(0)
627 request.prepath.append(pathElement)
628 return urllib.unquote(pathElement).decode("utf-8")
629
630 def _filterPathValue(self, value, handler, name, request):
631 """Modify a path value according to handler (see [getPathArgs])"""
632 if handler in (u"@", u"@jid") and value == u"@":
633 value = None
634
635 if handler in (u"", u"@"):
636 if value is None:
637 return u""
638 elif handler in (u"jid", u"@jid"):
639 if value:
640 try:
641 return jid.JID(value)
642 except RuntimeError:
643 log.warning(_(u"invalid jid argument: {value}").format(value=value))
644 self.pageError(request, C.HTTP_BAD_REQUEST)
645 else:
646 return u""
647 else:
648 return handler(self, value, name, request)
649
650 return value
651
652 def getPathArgs(self, request, names, min_args=0, **kwargs):
653 """get several path arguments at once
654
655 Arguments will be put in request data.
656 Missing arguments will have None value
657 @param names(list[unicode]): list of arguments to get
658 @param min_args(int): if less than min_args are found, PageError is used with C.HTTP_BAD_REQUEST
659 use 0 to ignore
660 @param **kwargs: special value or optional callback to use for arguments
661 names of the arguments must correspond to those in names
662 special values may be:
663 - '': use empty string instead of None when no value is specified
664 - '@': if value of argument is empty or '@', empty string will be used
665 - 'jid': value must be converted to jid.JID if it exists, else empty string is used
666 - '@jid': if value of arguments is empty or '@', empty string will be used, else it will be converted to jid
667 """
668 data = self.getRData(request)
669
670 for idx, name in enumerate(names):
671 if name[0] == u"*":
672 value = data[name[1:]] = []
673 while True:
674 try:
675 value.append(self.nextPath(request))
676 except IndexError:
677 idx -= 1
678 break
679 else:
680 idx += 1
681 else:
682 try:
683 value = data[name] = self.nextPath(request)
684 except IndexError:
685 data[name] = None
686 idx -= 1
687 break
688
689 values_count = idx + 1
690 if values_count < min_args:
691 log.warning(
692 _(
693 u"Missing arguments in URL (got {count}, expected at least {min_args})"
694 ).format(count=values_count, min_args=min_args)
695 )
696 self.pageError(request, C.HTTP_BAD_REQUEST)
697
698 for name in names[values_count:]:
699 data[name] = None
700
701 for name, handler in kwargs.iteritems():
702 if name[0] == "*":
703 data[name] = [
704 self._filterPathValue(v, handler, name, request) for v in data[name]
705 ]
706 else:
707 data[name] = self._filterPathValue(data[name], handler, name, request)
708
709 ## Cache handling ##
710
711 def _setCacheHeaders(self, request, cache):
712 """Set ETag and Last-Modified HTTP headers, used for caching"""
713 request.setHeader("ETag", cache.hash)
714 last_modified = self.host.getHTTPDate(cache.created)
715 request.setHeader("Last-Modified", last_modified)
716
717 def _checkCacheHeaders(self, request, cache):
718 """Check if a cache condition is set on the request
719
720 if condition is valid, C.HTTP_NOT_MODIFIED is returned
721 """
722 etag_match = request.getHeader("If-None-Match")
723 if etag_match is not None:
724 if cache.hash == etag_match:
725 self.pageError(request, C.HTTP_NOT_MODIFIED, no_body=True)
726 else:
727 modified_match = request.getHeader("If-Modified-Since")
728 if modified_match is not None:
729 modified = date_utils.date_parse(modified_match)
730 if modified >= int(cache.created):
731 self.pageError(request, C.HTTP_NOT_MODIFIED, no_body=True)
732
733 def checkCacheSubscribeCb(self, sub_id, service, node):
734 self.cache_pubsub_sub.add((service, node, sub_id))
735
736 def checkCacheSubscribeEb(self, failure_, service, node):
737 log.warning(_(u"Can't subscribe to node: {msg}").format(msg=failure_))
738 # FIXME: cache must be marked as unusable here
739
740 def psNodeWatchAddEb(self, failure_, service, node):
741 log.warning(_(u"Can't add node watched: {msg}").format(msg=failure_))
742
743 def checkCache(self, request, cache_type, **kwargs):
744 """check if a page is in cache and return cached version if suitable
745
746 this method may perform extra operation to handle cache (e.g. subscribing to a
747 pubsub node)
748 @param request(server.Request): current HTTP request
749 @param cache_type(int): on of C.CACHE_* const.
750 @param **kwargs: args according to cache_type:
751 C.CACHE_PUBSUB:
752 service: pubsub service
753 node: pubsub node
754 short: short name of feature (needed if node is empty to find namespace)
755
756 """
757 if request.postpath:
758 #  we are not on the final page, no need to go further
759 return
760
761 profile = self.getProfile(request) or C.SERVICE_PROFILE
762
763 if cache_type == C.CACHE_PUBSUB:
764 service, node = kwargs["service"], kwargs["node"]
765 if not node:
766 try:
767 short = kwargs["short"]
768 node = self.host.ns_map[short]
769 except KeyError:
770 log.warning(
771 _(
772 u'Can\'t use cache for empty node without namespace set, please ensure to set "short" and that it is registered'
773 )
774 )
775 return
776 if profile != C.SERVICE_PROFILE:
777 #  only service profile is cache for now
778 return
779 try:
780 cache = self.cache[profile][cache_type][service][node][request.uri][self]
781 except KeyError:
782 # no cache yet, let's subscribe to the pubsub node
783 d1 = self.host.bridgeCall(
784 "psSubscribe", service.full(), node, {}, profile
785 )
786 d1.addCallback(self.checkCacheSubscribeCb, service, node)
787 d1.addErrback(self.checkCacheSubscribeEb, service, node)
788 d2 = self.host.bridgeCall("psNodeWatchAdd", service.full(), node, profile)
789 d2.addErrback(self.psNodeWatchAddEb, service, node)
790 self._do_cache = [self, profile, cache_type, service, node, request.uri]
791 #  we don't return the Deferreds as it is not needed to wait for
792 # the subscription to continue with page rendering
793 return
794
795 else:
796 raise exceptions.InternalError(u"Unknown cache_type")
797 log.debug(u"using cache for {page}".format(page=self))
798 cache.last_access = time.time()
799 self._setCacheHeaders(request, cache)
800 self._checkCacheHeaders(request, cache)
801 request.write(cache.rendered)
802 request.finish()
803 raise failure.Failure(exceptions.CancelError(u"cache is used"))
804
805 def _cacheURL(self, dummy, request, profile):
806 self.cached_urls.setdefault(profile, {})[request.uri] = CacheURL(request)
807
808 @classmethod
809 def onNodeEvent(cls, host, service, node, event_type, items, profile):
810 """Invalidate cache for all pages linked to this node"""
811 try:
812 cache = cls.cache[profile][C.CACHE_PUBSUB][jid.JID(service)][node]
813 except KeyError:
814 log.info(
815 _(
816 u"Removing subscription for {service}/{node}: "
817 u"the page is not cached"
818 ).format(service=service, node=node)
819 )
820 d1 = host.bridgeCall("psUnsubscribe", service, node, profile)
821 d1.addErrback(
822 lambda failure_: log.warning(
823 _(u"Can't unsubscribe from {service}/{node}: {msg}").format(
824 service=service, node=node, msg=failure_
825 )
826 )
827 )
828 d2 = host.bridgeCall("psNodeWatchAdd", service, node, profile)
829 # TODO: check why the page is not in cache, remove subscription?
830 d2.addErrback(
831 lambda failure_: log.warning(
832 _(u"Can't remove watch for {service}/{node}: {msg}").format(
833 service=service, node=node, msg=failure_
834 )
835 )
836 )
837 else:
838 cache.clear()
839
840 @classmethod
841 def onSignal(cls, host, signal, *args):
842 """Generic method which receive registered signals
843
844 if a callback is registered for this signal, call it
845 @param host: Libervia instance
846 @param signal(unicode): name of the signal
847 @param *args: args of the signals
848 """
849 for page, request, check_profile in cls.signals_handlers.get(
850 signal, {}
851 ).itervalues():
852 if check_profile:
853 signal_profile = args[-1]
854 request_profile = page.getProfile(request)
855 if not request_profile:
856 # if you want to use signal without session, unset check_profile
857 # (be sure to know what you are doing)
858 log.error(_(u"no session started, signal can't be checked"))
859 continue
860 if signal_profile != request_profile:
861 #  we ignore the signal, it's not for our profile
862 continue
863 if request._signals_cache is not None:
864 # socket is not yet opened, we cache the signal
865 request._signals_cache.append((request, signal, args))
866 log.debug(
867 u"signal [{signal}] cached: {args}".format(signal=signal, args=args)
868 )
869 else:
870 page.on_signal(page, request, signal, *args)
871
872 def onSocketOpen(self, request):
873 """Called for dynamic pages when socket has just been opened
874
875 we send all cached signals
876 """
877 assert request._signals_cache is not None
878 cache = request._signals_cache
879 request._signals_cache = None
880 for request, signal, args in cache:
881 self.on_signal(self, request, signal, *args)
882
883 def onSocketClose(self, request):
884 """Called for dynamic pages when socket has just been closed
885
886 we remove signal handler
887 """
888 for signal in request._signals_registered:
889 try:
890 del LiberviaPage.signals_handlers[signal][id(request)]
891 except KeyError:
892 log.error(
893 _(
894 u"Can't find signal handler for [{signal}], this should not happen"
895 ).format(signal=signal)
896 )
897 else:
898 log.debug(_(u"Removed signal handler"))
899
900 def delegateToResource(self, request, resource):
901 """continue workflow with Twisted Resource"""
902 buf = resource.render(request)
903 if buf == server.NOT_DONE_YET:
904 pass
905 else:
906 request.write(buf)
907 request.finish()
908 raise failure.Failure(exceptions.CancelError(u"resource delegation"))
909
910 def HTTPRedirect(self, request, url):
911 """redirect to an URL using HTTP redirection
912
913 @param request(server.Request): current HTTP request
914 @param url(unicode): url to redirect to
915 """
916 web_util.redirectTo(url.encode("utf-8"), request)
917 request.finish()
918 raise failure.Failure(exceptions.CancelError(u"HTTP redirection is used"))
919
920 def redirectOrContinue(self, request, redirect_arg=u"redirect_url"):
921 """helper method to redirect a page to an url given as arg
922
923 if the arg is not present, the page will continue normal workflow
924 @param request(server.Request): current HTTP request
925 @param redirect_arg(unicode): argument to use to get redirection URL
926 @interrupt: redirect the page to requested URL
927 @interrupt pageError(C.HTTP_BAD_REQUEST): empty or non local URL is used
928 """
929 try:
930 url = request.args["redirect_url"][0]
931 except (KeyError, IndexError):
932 pass
933 else:
934 #  a redirection is requested
935 if not url or url[0] != u"/":
936 # we only want local urls
937 self.pageError(request, C.HTTP_BAD_REQUEST)
938 else:
939 self.HTTPRedirect(request, url)
940
941 def pageRedirect(self, page_path, request, skip_parse_url=True, path_args=None):
942 """redirect a page to a named page
943
944 the workflow will continue with the workflow of the named page,
945 skipping named page's parse_url method if it exist.
946 If you want to do a HTTP redirection, use HTTPRedirect
947 @param page_path(unicode): path to page (elements are separated by "/"):
948 if path starts with a "/":
949 path is a full path starting from root
950 else:
951 - first element is name as registered in name variable
952 - following element are subpages path
953 e.g.: "blog" redirect to page named "blog"
954 "blog/atom.xml" redirect to atom.xml subpage of "blog"
955 "/common/blog/atom.xml" redirect to the page at the fiven full path
956 @param request(server.Request): current HTTP request
957 @param skip_parse_url(bool): if True, parse_url method on redirect page will be skipped
958 @param path_args(list[unicode], None): path arguments to use in redirected page
959 @raise KeyError: there is no known page with this name
960 """
961 # FIXME: render non LiberviaPage resources
962 path = page_path.rstrip(u"/").split(u"/")
963 if not path[0]:
964 redirect_page = self.host.root
965 else:
966 redirect_page = self.named_pages[path[0]]
967
968 for subpage in path[1:]:
969 if redirect_page is self.host.root:
970 redirect_page = redirect_page.children[subpage]
971 else:
972 redirect_page = redirect_page.original.children[subpage]
973
974 if path_args is not None:
975 args = [quote(a) for a in path_args]
976 request.postpath = args + request.postpath
977
978 if self._do_cache:
979 # if cache is needed, it will be handled by final page
980 redirect_page._do_cache = self._do_cache
981 self._do_cache = None
982
983 redirect_page.renderPage(request, skip_parse_url=skip_parse_url)
984 raise failure.Failure(exceptions.CancelError(u"page redirection is used"))
985
986 def pageError(self, request, code=C.HTTP_NOT_FOUND, no_body=False):
987 """generate an error page and terminate the request
988
989 @param request(server.Request): HTTP request
990 @param core(int): error code to use
991 @param no_body: don't write body if True
992 """
993 request.setResponseCode(code)
994 if no_body:
995 request.finish()
996 else:
997 template = u"error/" + unicode(code) + ".html"
998
999 rendered = self.host.renderer.render(
1000 template,
1001 root_path="/templates/",
1002 error_code=code,
1003 **request.template_data
1004 )
1005
1006 self.writeData(rendered, request)
1007 raise failure.Failure(exceptions.CancelError(u"error page is used"))
1008
1009 def writeData(self, data, request):
1010 """write data to transport and finish the request"""
1011 if data is None:
1012 self.pageError(request)
1013 data_encoded = data.encode("utf-8")
1014
1015 if self._do_cache is not None:
1016 redirected_page = self._do_cache.pop(0)
1017 cache = reduce(lambda d, k: d.setdefault(k, {}), self._do_cache, self.cache)
1018 page_cache = cache[redirected_page] = CachePage(data_encoded)
1019 self._setCacheHeaders(request, page_cache)
1020 log.debug(
1021 _(u"{page} put in cache for [{profile}]").format(
1022 page=self, profile=self._do_cache[0]
1023 )
1024 )
1025 self._do_cache = None
1026 self._checkCacheHeaders(request, page_cache)
1027
1028 request.write(data_encoded)
1029 request.finish()
1030
1031 def _subpagesHandler(self, dummy, request):
1032 """render subpage if suitable
1033
1034 this method checks if there is still an unmanaged part of the path
1035 and check if it corresponds to a subpage. If so, it render the subpage
1036 else it render a NoResource.
1037 If there is no unmanaged part of the segment, current page workflow is pursued
1038 """
1039 if request.postpath:
1040 subpage = self.nextPath(request)
1041 try:
1042 child = self.children[subpage]
1043 except KeyError:
1044 self.pageError(request)
1045 else:
1046 child.render(request)
1047 raise failure.Failure(exceptions.CancelError(u"subpage page is used"))
1048
1049 def _prepare_dynamic(self, dummy, request):
1050 # we need to activate dynamic page
1051 # we set data for template, and create/register token
1052 socket_token = unicode(uuid.uuid4())
1053 socket_url = self.host.getWebsocketURL(request)
1054 socket_debug = C.boolConst(self.host.debug)
1055 request.template_data["websocket"] = WebsocketMeta(
1056 socket_url, socket_token, socket_debug
1057 )
1058 self.host.registerWSToken(socket_token, self, request)
1059 # we will keep track of handlers to remove
1060 request._signals_registered = []
1061 # we will cache registered signals until socket is opened
1062 request._signals_cache = []
1063
1064 def _prepare_render(self, dummy, request):
1065 return defer.maybeDeferred(self.prepare_render, self, request)
1066
1067 def _render_method(self, dummy, request):
1068 return defer.maybeDeferred(self.render_method, self, request)
1069
1070 def _render_template(self, dummy, request):
1071 template_data = request.template_data
1072
1073 # if confirm variable is set in case of successfuly data post
1074 session_data = self.host.getSessionData(request, session_iface.ISATSession)
1075 if session_data.popPageFlag(self, C.FLAG_CONFIRM):
1076 template_data[u"confirm"] = True
1077
1078 return self.host.renderer.render(
1079 self.template,
1080 root_path="/templates/",
1081 media_path="/" + C.MEDIA_DIR,
1082 cache_path=session_data.cache_dir,
1083 main_menu=LiberviaPage.main_menu,
1084 **template_data
1085 )
1086
1087 def _renderEb(self, failure_, request):
1088 """don't raise error on CancelError"""
1089 failure_.trap(exceptions.CancelError)
1090
1091 def _internalError(self, failure_, request):
1092 """called if an error is not catched"""
1093 log.error(
1094 _(u"Uncatched error for HTTP request on {url}: {msg}").format(
1095 url=request.URLPath(), msg=failure_
1096 )
1097 )
1098 self.pageError(request, C.HTTP_INTERNAL_ERROR)
1099
1100 def _on_data_post_redirect(self, ret, request):
1101 """called when page's on_data_post has been done successfuly
1102
1103 This will do a Post/Redirect/Get pattern.
1104 this method redirect to the same page or to request.data['post_redirect_page']
1105 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.
1106 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.
1107 HTTP status code "See Other" (303) is used as it is the recommanded code in this case.
1108 @param ret(None, unicode, iterable): on_data_post return value
1109 see LiberviaPage.__init__ on_data_post docstring
1110 """
1111 if ret is None:
1112 ret = ()
1113 elif isinstance(ret, basestring):
1114 ret = (ret,)
1115 else:
1116 ret = tuple(ret)
1117 raise NotImplementedError(
1118 _(u"iterable in on_data_post return value is not used yet")
1119 )
1120 session_data = self.host.getSessionData(request, session_iface.ISATSession)
1121 request_data = self.getRData(request)
1122 if "post_redirect_page" in request_data:
1123 redirect_page_data = request_data["post_redirect_page"]
1124 if isinstance(redirect_page_data, tuple):
1125 redirect_page = redirect_page_data[0]
1126 redirect_page_args = redirect_page_data[1:]
1127 redirect_uri = redirect_page.getURL(*redirect_page_args)
1128 else:
1129 redirect_page = redirect_page_data
1130 redirect_uri = redirect_page.url
1131 else:
1132 redirect_page = self
1133 redirect_uri = request.uri
1134
1135 if not C.POST_NO_CONFIRM in ret:
1136 session_data.setPageFlag(redirect_page, C.FLAG_CONFIRM)
1137 request.setResponseCode(C.HTTP_SEE_OTHER)
1138 request.setHeader("location", redirect_uri)
1139 request.finish()
1140 raise failure.Failure(exceptions.CancelError(u"Post/Redirect/Get is used"))
1141
1142 def _on_data_post(self, dummy, request):
1143 csrf_token = self.host.getSessionData(
1144 request, session_iface.ISATSession
1145 ).csrf_token
1146 try:
1147 given_csrf = self.getPostedData(request, u"csrf_token")
1148 except KeyError:
1149 given_csrf = None
1150 if given_csrf is None or given_csrf != csrf_token:
1151 log.warning(
1152 _(u"invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format(
1153 url=request.uri, ip=request.getClientIP()
1154 )
1155 )
1156 self.pageError(request, C.HTTP_UNAUTHORIZED)
1157 d = defer.maybeDeferred(self.on_data_post, self, request)
1158 d.addCallback(self._on_data_post_redirect, request)
1159 return d
1160
1161 def getPostedData(self, request, keys, multiple=False):
1162 """get data from a POST request or from URL's query part and decode it
1163
1164 @param request(server.Request): request linked to the session
1165 @param keys(unicode, iterable[unicode]): name of the value(s) to get
1166 unicode to get one value
1167 iterable to get more than one
1168 @param multiple(bool): True if multiple values are possible/expected
1169 if False, the first value is returned
1170 @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]): values received for this(these) key(s)
1171 @raise KeyError: one specific key has been requested, and it is missing
1172 """
1173 #  FIXME: request.args is already unquoting the value, it seems we are doing double unquote
1174 if isinstance(keys, basestring):
1175 keys = [keys]
1176 get_first = True
1177 else:
1178 get_first = False
1179
1180 ret = []
1181 for key in keys:
1182 gen = (urllib.unquote(v).decode("utf-8") for v in request.args.get(key, []))
1183 if multiple:
1184 ret.append(gen)
1185 else:
1186 try:
1187 ret.append(next(gen))
1188 except StopIteration:
1189 raise KeyError(key)
1190
1191 return ret[0] if get_first else ret
1192
1193 def getAllPostedData(self, request, except_=(), multiple=True):
1194 """get all posted data
1195
1196 @param request(server.Request): request linked to the session
1197 @param except_(iterable[unicode]): key of values to ignore
1198 csrf_token will always be ignored
1199 @param multiple(bool): if False, only the first values are returned
1200 @return (dict[unicode, list[unicode]]): post values
1201 """
1202 except_ = tuple(except_) + (u"csrf_token",)
1203 ret = {}
1204 for key, values in request.args.iteritems():
1205 key = urllib.unquote(key).decode("utf-8")
1206 if key in except_:
1207 continue
1208 if not multiple:
1209 ret[key] = urllib.unquote(values[0]).decode("utf-8")
1210 else:
1211 ret[key] = [urllib.unquote(v).decode("utf-8") for v in values]
1212 return ret
1213
1214 def getProfile(self, request):
1215 """helper method to easily get current profile
1216
1217 @return (unicode, None): current profile
1218 None if no profile session is started
1219 """
1220 sat_session = self.host.getSessionData(request, session_iface.ISATSession)
1221 return sat_session.profile
1222
1223 def getRData(self, request):
1224 """helper method to get request data dict
1225
1226 this dictionnary if for the request only, it is not saved in session
1227 It is mainly used to pass data between pages/methods called during request workflow
1228 @return (dict): request data
1229 """
1230 try:
1231 return request.data
1232 except AttributeError:
1233 request.data = {}
1234 return request.data
1235
1236 def _checkAccess(self, data, request):
1237 """Check access according to self.access
1238
1239 if access is not granted, show a HTTP_UNAUTHORIZED pageError and stop request,
1240 else return data (so it can be inserted in deferred chain
1241 """
1242 if self.access == C.PAGES_ACCESS_PUBLIC:
1243 pass
1244 elif self.access == C.PAGES_ACCESS_PROFILE:
1245 profile = self.getProfile(request)
1246 if not profile:
1247 # no session started
1248 if not self.host.options["allow_registration"]:
1249 # registration not allowed, access is not granted
1250 self.pageError(request, C.HTTP_UNAUTHORIZED)
1251 else:
1252 # registration allowed, we redirect to login page
1253 login_url = self.getPageRedirectURL(request)
1254 self.HTTPRedirect(request, login_url)
1255
1256 return data
1257
1258 def renderPartial(self, request, template, template_data):
1259 """Render a template to be inserted in dynamic page
1260
1261 this is NOT the normal page rendering method, it is used only to update
1262 dynamic pages
1263 @param template(unicode): path of the template to render
1264 @param template_data(dict): template_data to use
1265 """
1266 if not self.dynamic:
1267 raise exceptions.InternalError(
1268 _(u"renderPartial must only be used with dynamic pages")
1269 )
1270 session_data = self.host.getSessionData(request, session_iface.ISATSession)
1271
1272 return self.host.renderer.render(
1273 template,
1274 root_path="/templates/",
1275 media_path="/" + C.MEDIA_DIR,
1276 cache_path=session_data.cache_dir,
1277 main_menu=LiberviaPage.main_menu,
1278 **template_data
1279 )
1280
1281 def renderAndUpdate(
1282 self, request, template, selectors, template_data_update, update_type="append"
1283 ):
1284 """Helper method to render a partial page element and update the page
1285
1286 this is NOT the normal page rendering method, it is used only to update
1287 dynamic pages
1288 @param request(server.Request): current HTTP request
1289 @param template: same as for [renderPartial]
1290 @param selectors: CSS selectors to use
1291 @param template_data_update: template data to use
1292 template data cached in request will be copied then updated
1293 with this data
1294 @parap update_type(unicode): one of:
1295 append: append rendered element to selected element
1296 """
1297 template_data = request.template_data.copy()
1298 template_data.update(template_data_update)
1299 html = self.renderPartial(request, template, template_data)
1300 request.sendData(u"dom", selectors=selectors, update_type=update_type, html=html)
1301
1302 def renderPage(self, request, skip_parse_url=False):
1303 """Main method to handle the workflow of a LiberviaPage"""
1304
1305 # template_data are the variables passed to template
1306 if not hasattr(request, "template_data"):
1307 session_data = self.host.getSessionData(request, session_iface.ISATSession)
1308 csrf_token = session_data.csrf_token
1309 request.template_data = {
1310 u"profile": session_data.profile,
1311 u"csrf_token": csrf_token,
1312 }
1313
1314 # XXX: here is the code which need to be executed once
1315 # at the beginning of the request hanling
1316 if request.postpath and not request.postpath[-1]:
1317 # we don't differenciate URLs finishing with '/' or not
1318 del request.postpath[-1]
1319
1320 d = defer.Deferred()
1321 d.addCallback(self._checkAccess, request)
1322
1323 if self.redirect is not None:
1324 d.addCallback(
1325 lambda dummy: self.pageRedirect(
1326 self.redirect, request, skip_parse_url=False
1327 )
1328 )
1329
1330 if self.parse_url is not None and not skip_parse_url:
1331 if self.url_cache:
1332 profile = self.getProfile(request)
1333 try:
1334 cache_url = self.cached_urls[profile][request.uri]
1335 except KeyError:
1336 # no cache for this URI yet
1337 #  we do normal URL parsing, and then the cache
1338 d.addCallback(self.parse_url, request)
1339 d.addCallback(self._cacheURL, request, profile)
1340 else:
1341 log.debug(_(u"using URI cache for {page}").format(page=self))
1342 cache_url.use(request)
1343 else:
1344 d.addCallback(self.parse_url, request)
1345
1346 d.addCallback(self._subpagesHandler, request)
1347
1348 if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST):
1349 # only HTTP GET and POST are handled so far
1350 d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST))
1351
1352 if request.method == C.HTTP_METHOD_POST:
1353 if self.on_data_post is None:
1354 # if we don't have on_data_post, the page was not expecting POST
1355 # so we return an error
1356 d.addCallback(lambda dummy: self.pageError(request, C.HTTP_BAD_REQUEST))
1357 else:
1358 d.addCallback(self._on_data_post, request)
1359 # by default, POST follow normal behaviour after on_data_post is called
1360 # this can be changed by a redirection or other method call in on_data_post
1361
1362 if self.dynamic:
1363 d.addCallback(self._prepare_dynamic, request)
1364
1365 if self.prepare_render:
1366 d.addCallback(self._prepare_render, request)
1367
1368 if self.template:
1369 d.addCallback(self._render_template, request)
1370 elif self.render_method:
1371 d.addCallback(self._render_method, request)
1372
1373 d.addCallback(self.writeData, request)
1374 d.addErrback(self._renderEb, request)
1375 d.addErrback(self._internalError, request)
1376 d.callback(self)
1377 return server.NOT_DONE_YET
1378
1379 def render_GET(self, request):
1380 return self.renderPage(request)
1381
1382 def render_POST(self, request):
1383 return self.renderPage(request)