comparison libervia/web/server/pages.py @ 1518:eb00d593801d

refactoring: rename `libervia` to `libervia.web` + update imports following backend changes
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 16:49:28 +0200
parents libervia/server/pages.py@16228994ca3b
children 66c1a90da1bc
comparison
equal deleted inserted replaced
1517:b8ed9726525b 1518:eb00d593801d
1 #!/usr/bin/env python3
2
3 # Libervia: a Salut à Toi frontend
4 # Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
5
6 # This program is free software: you can redistribute it and/or modify
7 # it under the terms of the GNU Affero General Public License as published by
8 # the Free Software Foundation, either version 3 of the License, or
9 # (at your option) any later version.
10
11 # This program is distributed in the hope that it will be useful,
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 # GNU Affero General Public License for more details.
15
16 # You should have received a copy of the GNU Affero General Public License
17 # along with this program. If not, see <http://www.gnu.org/licenses/>.
18
19 from __future__ import annotations
20 import copy
21 from functools import reduce
22 import hashlib
23 import json
24 import os.path
25 from pathlib import Path
26 import time
27 import traceback
28 from typing import List, Optional, Union
29 import urllib.error
30 import urllib.parse
31 import urllib.request
32 import uuid
33
34 from twisted.internet import defer
35 from twisted.python import failure
36 from twisted.python.filepath import FilePath
37 from twisted.web import server
38 from twisted.web import resource as web_resource
39 from twisted.web import util as web_util
40 from twisted.words.protocols.jabber import jid
41
42 from libervia.backend.core import exceptions
43 from libervia.backend.core.i18n import _
44 from libervia.backend.core.log import getLogger
45 from libervia.backend.tools.common import date_utils
46 from libervia.backend.tools.common import utils
47 from libervia.backend.tools.common import data_format
48 from libervia.backend.tools.utils import as_deferred
49 from libervia.frontends.bridge.bridge_frontend import BridgeException
50
51 from . import session_iface
52 from .classes import WebsocketMeta
53 from .classes import Script
54 from .constants import Const as C
55 from .resources import LiberviaRootResource
56 from .utils import SubPage, quote
57
58 log = getLogger(__name__)
59
60
61 class CacheBase(object):
62 def __init__(self):
63 self._created = time.time()
64 self._last_access = self._created
65
66 @property
67 def created(self):
68 return self._created
69
70 @property
71 def last_access(self):
72 return self._last_access
73
74 @last_access.setter
75 def last_access(self, timestamp):
76 self._last_access = timestamp
77
78
79 class CachePage(CacheBase):
80 def __init__(self, rendered):
81 super(CachePage, self).__init__()
82 self._created = time.time()
83 self._last_access = self._created
84 self._rendered = rendered
85 self._hash = hashlib.sha256(rendered).hexdigest()
86
87 @property
88 def rendered(self):
89 return self._rendered
90
91 @property
92 def hash(self):
93 return self._hash
94
95
96 class CacheURL(CacheBase):
97 def __init__(self, request):
98 super(CacheURL, self).__init__()
99 try:
100 self._data = copy.deepcopy(request.data)
101 except AttributeError:
102 self._data = {}
103 self._template_data = copy.deepcopy(request.template_data)
104 self._prepath = request.prepath[:]
105 self._postpath = request.postpath[:]
106 del self._template_data["csrf_token"]
107
108 def use(self, request):
109 self.last_access = time.time()
110 request.data = copy.deepcopy(self._data)
111 request.template_data.update(copy.deepcopy(self._template_data))
112 request.prepath = self._prepath[:]
113 request.postpath = self._postpath[:]
114
115
116 class LiberviaPage(web_resource.Resource):
117 isLeaf = True #  we handle subpages ourself
118 cache = {}
119 #  Set of tuples (service/node/sub_id) of nodes subscribed for caching
120 # sub_id can be empty string if not handled by service
121 cache_pubsub_sub = set()
122
123 def __init__(
124 self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None,
125 access=None, dynamic=True, parse_url=None, add_breadcrumb=None,
126 prepare_render=None, render=None, template=None, on_data_post=None, on_data=None,
127 url_cache=False, replace_on_conflict=False
128 ):
129 """Initiate LiberviaPage instance
130
131 LiberviaPages are the main resources of Libervia, using easy to set python files
132 The non mandatory arguments are the variables found in page_meta.py
133 @param host(Libervia): the running instance of Libervia
134 @param vhost_root(web_resource.Resource): root resource of the virtual host which
135 handle this page.
136 @param root_dir(Path): absolute file path of the page
137 @param url(unicode): relative URL to the page
138 this URL may not be valid, as pages may require path arguments
139 @param name(unicode, None): if not None, a unique name to identify the page
140 can then be used for e.g. redirection
141 "/" is not allowed in names (as it can be used to construct URL paths)
142 @param redirect(unicode, None): if not None, this page will be redirected.
143 A redirected parameter is used as in self.page_redirect.
144 parse_url will not be skipped
145 using this redirect parameter is called "full redirection"
146 using self.page_redirect is called "partial redirection" (because some
147 rendering method can still be used, e.g. parse_url)
148 @param access(unicode, None): permission needed to access the page
149 None means public access.
150 Pages inherit from parent pages: e.g. if a "settings" page is restricted
151 to admins, and if "settings/blog" is public, it still can only be accessed by
152 admins. See C.PAGES_ACCESS_* for details
153 @param dynamic(bool): if True, activate websocket for bidirectional communication
154 @param parse_url(callable, None): if set it will be called to handle the URL path
155 after this method, the page will be rendered if noting is left in path
156 (request.postpath) else a the request will be transmitted to a subpage
157 @param add_breadcrumb(callable, None): if set, manage the breadcrumb data for this
158 page, otherwise it will be set automatically from page name or label.
159 @param prepare_render(callable, None): if set, will be used to prepare the
160 rendering. That often means gathering data using the bridge
161 @param render(callable, None): if template is not set, this method will be
162 called and what it returns will be rendered.
163 This method is mutually exclusive with template and must return a unicode
164 string.
165 @param template(unicode, None): path to the template to render.
166 This method is mutually exclusive with render
167 @param on_data_post(callable, None): method to call when data is posted
168 None if data post is not handled
169 "continue" if data post is not handled there, and we must not interrupt
170 workflow (i.e. it's handled in "render" method).
171 otherwise, on_data_post can return a string with following value:
172 - C.POST_NO_CONFIRM: confirm flag will not be set
173 on_data_post can raise following exceptions:
174 - exceptions.DataError: value is incorrect, message will be displayed
175 as a notification
176 @param on_data(callable, None): method to call when dynamic data is sent
177 this method is used with Libervia's websocket mechanism
178 @param url_cache(boolean): if set, result of parse_url is cached (per profile).
179 Useful when costly calls (e.g. network) are done while parsing URL.
180 @param replace_on_conflict(boolean): if True, don't raise ConflictError if a
181 page of this name already exists, but replace it
182 """
183
184 web_resource.Resource.__init__(self)
185 self.host = host
186 self.vhost_root = vhost_root
187 self.root_dir = root_dir
188 self.url = url
189 self.name = name
190 self.label = label
191 self.dyn_data = {}
192 if name is not None:
193 if (name in self.named_pages
194 and not (replace_on_conflict and self.named_pages[name].url == url)):
195 raise exceptions.ConflictError(
196 _('a Libervia page named "{}" already exists'.format(name)))
197 if "/" in name:
198 raise ValueError(_('"/" is not allowed in page names'))
199 if not name:
200 raise ValueError(_("a page name can't be empty"))
201 self.named_pages[name] = self
202 if access is None:
203 access = C.PAGES_ACCESS_PUBLIC
204 if access not in (
205 C.PAGES_ACCESS_PUBLIC,
206 C.PAGES_ACCESS_PROFILE,
207 C.PAGES_ACCESS_NONE,
208 ):
209 raise NotImplementedError(
210 _("{} access is not implemented yet").format(access)
211 )
212 self.access = access
213 self.dynamic = dynamic
214 if redirect is not None:
215 # only page access and name make sense in case of full redirection
216 # so we check that rendering methods/values are not set
217 if not all(
218 lambda x: x is not None
219 for x in (parse_url, prepare_render, render, template)
220 ):
221 raise ValueError(
222 _("you can't use full page redirection with other rendering"
223 "method, check self.page_redirect if you need to use them"))
224 self.redirect = redirect
225 else:
226 self.redirect = None
227 self.parse_url = parse_url
228 self.add_breadcrumb = add_breadcrumb
229 self.prepare_render = prepare_render
230 self.template = template
231 self.render_method = render
232 self.on_data_post = on_data_post
233 self.on_data = on_data
234 self.url_cache = url_cache
235 if access == C.PAGES_ACCESS_NONE:
236 # none pages just return a 404, no further check is needed
237 return
238 if template is not None and render is not None:
239 log.error(_("render and template methods can't be used at the same time"))
240
241 # if not None, next rendering will be cached
242 #  it must then contain a list of the the keys to use (without the page instance)
243 # e.g. [C.SERVICE_PROFILE, "pubsub", server@example.tld, pubsub_node]
244 self._do_cache = None
245
246 def __str__(self):
247 return "LiberviaPage {name} at {url} (vhost: {vhost_root})".format(
248 name=self.name or "<anonymous>", url=self.url, vhost_root=self.vhost_root)
249
250 @property
251 def named_pages(self):
252 return self.vhost_root.named_pages
253
254 @property
255 def uri_callbacks(self):
256 return self.vhost_root.uri_callbacks
257
258 @property
259 def pages_redirects(self):
260 return self.vhost_root.pages_redirects
261
262 @property
263 def cached_urls(self):
264 return self.vhost_root.cached_urls
265
266 @property
267 def main_menu(self):
268 return self.vhost_root.main_menu
269
270 @property
271 def default_theme(self):
272 return self.vhost_root.default_theme
273
274
275 @property
276 def site_themes(self):
277 return self.vhost_root.site_themes
278
279 @staticmethod
280 def create_page(host, meta_path, vhost_root, url_elts, replace_on_conflict=False):
281 """Create a LiberviaPage instance
282
283 @param meta_path(Path): path to the page_meta.py file
284 @param vhost_root(resource.Resource): root resource of the virtual host
285 @param url_elts(list[unicode]): list of path element from root site to this page
286 @param replace_on_conflict(bool): same as for [LiberviaPage]
287 @return (tuple[dict, LiberviaPage]): tuple with:
288 - page_data: dict containing data of the page
289 - libervia_page: created resource
290 """
291 dir_path = meta_path.parent
292 page_data = {"__name__": ".".join(["page"] + url_elts)}
293 # we don't want to force the presence of __init__.py
294 # so we use execfile instead of import.
295 # TODO: when moved to Python 3, __init__.py is not mandatory anymore
296 # so we can switch to import
297 exec(compile(open(meta_path, "rb").read(), meta_path, 'exec'), page_data)
298 return page_data, LiberviaPage(
299 host=host,
300 vhost_root=vhost_root,
301 root_dir=dir_path,
302 url="/" + "/".join(url_elts),
303 name=page_data.get("name"),
304 label=page_data.get("label"),
305 redirect=page_data.get("redirect"),
306 access=page_data.get("access"),
307 dynamic=page_data.get("dynamic", True),
308 parse_url=page_data.get("parse_url"),
309 add_breadcrumb=page_data.get("add_breadcrumb"),
310 prepare_render=page_data.get("prepare_render"),
311 render=page_data.get("render"),
312 template=page_data.get("template"),
313 on_data_post=page_data.get("on_data_post"),
314 on_data=page_data.get("on_data"),
315 url_cache=page_data.get("url_cache", False),
316 replace_on_conflict=replace_on_conflict
317 )
318
319 @staticmethod
320 def create_browser_data(
321 vhost_root,
322 resource: Optional[LiberviaPage],
323 browser_path: Path,
324 path_elts: Optional[List[str]],
325 engine: str = "brython"
326 ) -> None:
327 """create and store data for browser dynamic code"""
328 dyn_data = {
329 "path": browser_path,
330 "url_hash": (
331 hashlib.sha256('/'.join(path_elts).encode()).hexdigest()
332 if path_elts is not None else None
333 ),
334 }
335 browser_meta_path = browser_path / C.PAGES_BROWSER_META_FILE
336 if browser_meta_path.is_file():
337 with browser_meta_path.open() as f:
338 browser_meta = json.load(f)
339 utils.recursive_update(vhost_root.browser_modules, browser_meta)
340 if resource is not None:
341 utils.recursive_update(resource.dyn_data, browser_meta)
342
343 init_path = browser_path / '__init__.py'
344 if init_path.is_file():
345 vhost_root.browser_modules.setdefault(
346 engine, []).append(dyn_data)
347 if resource is not None:
348 resource.dyn_data[engine] = dyn_data
349 elif path_elts is None:
350 try:
351 next(browser_path.glob('*.py'))
352 except StopIteration:
353 # no python file, nothing for Brython
354 pass
355 else:
356 vhost_root.browser_modules.setdefault(
357 engine, []).append(dyn_data)
358
359
360 @classmethod
361 def import_pages(cls, host, vhost_root, root_path=None, _parent=None, _path=None,
362 _extra_pages=False):
363 """Recursively import Libervia pages
364
365 @param host(Libervia): Libervia instance
366 @param vhost_root(LiberviaRootResource): root of this VirtualHost
367 @param root_path(Path, None): use this root path instead of vhost_root's one
368 Used to add default site pages to external sites
369 @param _parent(Resource, None): _parent page. Do not set yourself, this is for
370 internal use only
371 @param _path(list(unicode), None): current path. Do not set yourself, this is for
372 internal use only
373 @param _extra_pages(boolean): set to True when extra pages are used (i.e.
374 root_path is set). Do not set yourself, this is for internal use only
375 """
376 if _path is None:
377 _path = []
378 if _parent is None:
379 if root_path is None:
380 root_dir = vhost_root.site_path / C.PAGES_DIR
381 else:
382 root_dir = root_path / C.PAGES_DIR
383 _extra_pages = True
384 _parent = vhost_root
385 root_browser_path = root_dir / C.PAGES_BROWSER_DIR
386 if root_browser_path.is_dir():
387 cls.create_browser_data(vhost_root, None, root_browser_path, None)
388 else:
389 root_dir = _parent.root_dir
390
391 for d in os.listdir(root_dir):
392 dir_path = root_dir / d
393 if not dir_path.is_dir():
394 continue
395 if _extra_pages and d in _parent.children:
396 log.debug(_("[{host_name}] {path} is already present, ignoring it")
397 .format(host_name=vhost_root.host_name, path='/'.join(_path+[d])))
398 continue
399 meta_path = dir_path / C.PAGES_META_FILE
400 if meta_path.is_file():
401 new_path = _path + [d]
402 try:
403 page_data, resource = cls.create_page(
404 host, meta_path, vhost_root, new_path)
405 except exceptions.ConflictError as e:
406 if _extra_pages:
407 # extra pages are discarded if there is already an existing page
408 continue
409 else:
410 raise e
411 _parent.putChild(str(d).encode(), resource)
412 log_msg = ("[{host_name}] Added /{path} page".format(
413 host_name=vhost_root.host_name,
414 path="[…]/".join(new_path)))
415 if _extra_pages:
416 log.debug(log_msg)
417 else:
418 log.info(log_msg)
419 if "uri_handlers" in page_data:
420 if not isinstance(page_data, dict):
421 log.error(_("uri_handlers must be a dict"))
422 else:
423 for uri_tuple, cb_name in page_data["uri_handlers"].items():
424 if len(uri_tuple) != 2 or not isinstance(cb_name, str):
425 log.error(_("invalid uri_tuple"))
426 continue
427 if not _extra_pages:
428 log.info(_("setting {}/{} URIs handler")
429 .format(*uri_tuple))
430 try:
431 cb = page_data[cb_name]
432 except KeyError:
433 log.error(_("missing {name} method to handle {1}/{2}")
434 .format(name=cb_name, *uri_tuple))
435 continue
436 else:
437 resource.register_uri(uri_tuple, cb)
438
439 LiberviaPage.import_pages(
440 host, vhost_root, _parent=resource, _path=new_path,
441 _extra_pages=_extra_pages)
442 # now we check if there is some code for browser
443 browser_path = dir_path / C.PAGES_BROWSER_DIR
444 if browser_path.is_dir():
445 cls.create_browser_data(vhost_root, resource, browser_path, new_path)
446
447 @classmethod
448 def on_file_change(
449 cls,
450 host,
451 file_path: FilePath,
452 flags: List[str],
453 site_root: LiberviaRootResource,
454 site_path: Path
455 ) -> None:
456 """Method triggered by file_watcher when something is changed in files
457
458 This method is used in dev mode to reload pages when needed
459 @param file_path: path of the file which triggered the event
460 @param flags: human readable flags of the event (from
461 internet.inotify)
462 @param site_root: root of the site
463 @param site_path: absolute path of the site
464 """
465 if flags == ['create']:
466 return
467 path = Path(file_path.path.decode())
468 base_name = path.name
469 if base_name != "page_meta.py":
470 # we only handle libervia pages
471 return
472
473 log.debug("{flags} event(s) received for {file_path}".format(
474 flags=", ".join(flags), file_path=file_path))
475
476 dir_path = path.parent
477
478 if dir_path == site_path:
479 return
480
481 if not site_path in dir_path.parents:
482 raise exceptions.InternalError("watched file should be in a subdirectory of site path")
483
484 path_elts = list(dir_path.relative_to(site_path).parts)
485
486 if path_elts[0] == C.PAGES_DIR:
487 # a page has been modified
488 del path_elts[0]
489 if not path_elts:
490 # we need at least one element to parse
491 return
492 # we retrieve page by starting from site root and finding each path element
493 parent = page = site_root
494 new_page = False
495 for idx, child_name in enumerate(path_elts):
496 child_name = child_name.encode()
497 try:
498 try:
499 page = page.original.children[child_name]
500 except AttributeError:
501 page = page.children[child_name]
502 except KeyError:
503 if idx != len(path_elts)-1:
504 # a page has been created in a subdir when one or more
505 # page_meta.py are missing on the way
506 log.warning(_("Can't create a page at {path}, missing parents")
507 .format(path=path))
508 return
509 new_page = True
510 else:
511 if idx<len(path_elts)-1:
512 try:
513 parent = page.original
514 except AttributeError:
515 parent = page
516
517 try:
518 # we (re)create a page with the new/modified code
519 __, resource = cls.create_page(host, path, site_root, path_elts,
520 replace_on_conflict=True)
521 if not new_page:
522 try:
523 resource.children = page.original.children
524 except AttributeError:
525 # FIXME: this .original handling madness is due to EncodingResourceWrapper
526 # EncodingResourceWrapper should probably be removed
527 resource.children = page.children
528 except Exception as e:
529 log.warning(_("Can't create page: {reason}").format(reason=e))
530 else:
531 url_elt = path_elts[-1].encode()
532 if not new_page:
533 # the page was already existing, we remove it
534 del parent.children[url_elt]
535 # we can now add the new page
536 parent.putChild(url_elt, resource)
537
538 # is there any browser data to create?
539 browser_path = resource.root_dir / C.PAGES_BROWSER_DIR
540 if browser_path.is_dir():
541 cls.create_browser_data(
542 resource.vhost_root,
543 resource,
544 browser_path,
545 resource.url.split('/')
546 )
547
548 if new_page:
549 log.info(_("{page} created").format(page=resource))
550 else:
551 log.info(_("{page} reloaded").format(page=resource))
552
553 def check_csrf(self, request):
554 session = self.host.get_session_data(
555 request, session_iface.IWebSession
556 )
557 if session.profile is None:
558 # CSRF doesn't make sense when no user is logged
559 log.debug("disabling CSRF check because service profile is used")
560 return
561 csrf_token = session.csrf_token
562 given_csrf = request.getHeader("X-Csrf-Token")
563 if given_csrf is None:
564 try:
565 given_csrf = self.get_posted_data(request, "csrf_token")
566 except KeyError:
567 pass
568 if given_csrf is None or given_csrf != csrf_token:
569 log.warning(
570 _("invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format(
571 url=request.uri, ip=request.getClientIP()
572 )
573 )
574 self.page_error(request, C.HTTP_FORBIDDEN)
575
576 def expose_to_scripts(
577 self,
578 request: server.Request,
579 **kwargs: str
580 ) -> None:
581 """Make a local variable available to page script as a global variable
582
583 No check is done for conflicting name, use this carefully
584 """
585 template_data = request.template_data
586 scripts = template_data.setdefault("scripts", utils.OrderedSet())
587 for name, value in kwargs.items():
588 if value is None:
589 value = "null"
590 elif isinstance(value, str):
591 # FIXME: workaround for subtype used by python-dbus (dbus.String)
592 # to be removed when we get rid of python-dbus
593 value = repr(str(value))
594 else:
595 value = repr(value)
596 scripts.add(Script(content=f"var {name}={value};"))
597
598 def register_uri(self, uri_tuple, get_uri_cb):
599 """Register a URI handler
600
601 @param uri_tuple(tuple[unicode, unicode]): type or URIs handler
602 type/subtype as returned by tools/common/parse_xmpp_uri
603 or type/None to handle all subtypes
604 @param get_uri_cb(callable): method which take uri_data dict as only argument
605 and return absolute path with correct arguments or None if the page
606 can't handle this URL
607 """
608 if uri_tuple in self.uri_callbacks:
609 log.info(_("{}/{} URIs are already handled, replacing by the new handler")
610 .format( *uri_tuple))
611 self.uri_callbacks[uri_tuple] = (self, get_uri_cb)
612
613 def config_get(self, key, default=None, value_type=None):
614 return self.host.config_get(self.vhost_root, key=key, default=default,
615 value_type=value_type)
616
617 def get_build_path(self, session_data):
618 return session_data.cache_dir + self.vhost.site_name
619
620 def get_page_by_name(self, name):
621 return self.vhost_root.get_page_by_name(name)
622
623 def get_page_path_from_uri(self, uri):
624 return self.vhost_root.get_page_path_from_uri(uri)
625
626 def get_page_redirect_url(self, request, page_name="login", url=None):
627 """generate URL for a page with redirect_url parameter set
628
629 mainly used for login page with redirection to current page
630 @param request(server.Request): current HTTP request
631 @param page_name(unicode): name of the page to go
632 @param url(None, unicode): url to redirect to
633 None to use request path (i.e. current page)
634 @return (unicode): URL to use
635 """
636 return "{root_url}?redirect_url={redirect_url}".format(
637 root_url=self.get_page_by_name(page_name).url,
638 redirect_url=urllib.parse.quote_plus(request.uri)
639 if url is None
640 else url.encode("utf-8"),
641 )
642
643 def get_url(self, *args: str, **kwargs: str) -> str:
644 """retrieve URL of the page set arguments
645
646 @param *args: arguments to add to the URL as path elements empty or None
647 arguments will be ignored
648 @param **kwargs: query parameters
649 """
650 url_args = [quote(a) for a in args if a]
651
652 if self.name is not None and self.name in self.pages_redirects:
653 #  we check for redirection
654 redirect_data = self.pages_redirects[self.name]
655 args_hash = tuple(args)
656 for limit in range(len(args), -1, -1):
657 current_hash = args_hash[:limit]
658 if current_hash in redirect_data:
659 url_base = redirect_data[current_hash]
660 remaining = args[limit:]
661 remaining_url = "/".join(remaining)
662 url = urllib.parse.urljoin(url_base, remaining_url)
663 break
664 else:
665 url = os.path.join(self.url, *url_args)
666 else:
667 url = os.path.join(self.url, *url_args)
668
669 if kwargs:
670 encoded = urllib.parse.urlencode(
671 {k: v for k, v in kwargs.items()}
672 )
673 url += f"?{encoded}"
674
675 return self.host.check_redirection(
676 self.vhost_root,
677 url
678 )
679
680 def get_current_url(self, request):
681 """retrieve URL used to access this page
682
683 @return(unicode): current URL
684 """
685 # we get url in the following way (splitting request.path instead of using
686 # request.prepath) because request.prepath may have been modified by
687 # redirection (if redirection args have been specified), while path reflect
688 # the real request
689
690 # we ignore empty path elements (i.e. double '/' or '/' at the end)
691 path_elts = [p for p in request.path.decode('utf-8').split("/") if p]
692
693 if request.postpath:
694 if not request.postpath[-1]:
695 #  we remove trailing slash
696 request.postpath = request.postpath[:-1]
697 if request.postpath:
698 #  get_sub_page_url must return subpage from the point where
699 # the it is called, so we have to remove remanining
700 # path elements
701 path_elts = path_elts[: -len(request.postpath)]
702
703 return "/" + "/".join(path_elts)
704
705 def get_param_url(self, request, **kwargs):
706 """use URL of current request but modify the parameters in query part
707
708 **kwargs(dict[str, unicode]): argument to use as query parameters
709 @return (unicode): constructed URL
710 """
711 current_url = self.get_current_url(request)
712 if kwargs:
713 encoded = urllib.parse.urlencode(
714 {k: v for k, v in kwargs.items()}
715 )
716 current_url = current_url + "?" + encoded
717 return current_url
718
719 def get_sub_page_by_name(self, subpage_name, parent=None):
720 """retrieve a subpage and its path using its name
721
722 @param subpage_name(unicode): name of the sub page
723 it must be a direct children of parent page
724 @param parent(LiberviaPage, None): parent page
725 None to use current page
726 @return (tuple[str, LiberviaPage]): page subpath and instance
727 @raise exceptions.NotFound: no page has been found
728 """
729 if parent is None:
730 parent = self
731 for path, child in parent.children.items():
732 try:
733 child_name = child.name
734 except AttributeError:
735 #  LiberviaPages have a name, but maybe this is an other Resource
736 continue
737 if child_name == subpage_name:
738 return path.decode('utf-8'), child
739 raise exceptions.NotFound(
740 _("requested sub page has not been found ({subpage_name})").format(
741 subpage_name=subpage_name))
742
743 def get_sub_page_url(self, request, page_name, *args):
744 """retrieve a page in direct children and build its URL according to request
745
746 request's current path is used as base (at current parsing point,
747 i.e. it's more prepath than path).
748 Requested page is checked in children and an absolute URL is then built
749 by the resulting combination.
750 This method is useful to construct absolute URLs for children instead of
751 using relative path, which may not work in subpages, and are linked to the
752 names of directories (i.e. relative URL will break if subdirectory is renamed
753 while get_sub_page_url won't as long as page_name is consistent).
754 Also, request.path is used, keeping real path used by user,
755 and potential redirections.
756 @param request(server.Request): current HTTP request
757 @param page_name(unicode): name of the page to retrieve
758 it must be a direct children of current page
759 @param *args(list[unicode]): arguments to add as path elements
760 if an arg is None, it will be ignored
761 @return (unicode): absolute URL to the sub page
762 """
763 current_url = self.get_current_url(request)
764 path, child = self.get_sub_page_by_name(page_name)
765 return os.path.join(
766 "/", current_url, path, *[quote(a) for a in args if a is not None]
767 )
768
769 def get_url_by_names(self, named_path):
770 """Retrieve URL from pages names and arguments
771
772 @param named_path(list[tuple[unicode, list[unicode]]]): path to the page as a list
773 of tuples of 2 items:
774 - first item is page name
775 - second item is list of path arguments of this page
776 @return (unicode): URL to the requested page with given path arguments
777 @raise exceptions.NotFound: one of the page was not found
778 """
779 current_page = None
780 path = []
781 for page_name, page_args in named_path:
782 if current_page is None:
783 current_page = self.get_page_by_name(page_name)
784 path.append(current_page.get_url(*page_args))
785 else:
786 sub_path, current_page = self.get_sub_page_by_name(
787 page_name, parent=current_page
788 )
789 path.append(sub_path)
790 if page_args:
791 path.extend([quote(a) for a in page_args])
792 return self.host.check_redirection(self.vhost_root, "/".join(path))
793
794 def get_url_by_path(self, *args):
795 """Generate URL by path
796
797 this method as a similar effect as get_url_by_names, but it is more readable
798 by using SubPage to get pages instead of using tuples
799 @param *args: path element:
800 - if unicode, will be used as argument
801 - if util.SubPage instance, must be the name of a subpage
802 @return (unicode): generated path
803 """
804 args = list(args)
805 if not args:
806 raise ValueError("You must specify path elements")
807 # root page is the one needed to construct the base of the URL
808 # if first arg is not a SubPage instance, we use current page
809 if not isinstance(args[0], SubPage):
810 root = self
811 else:
812 root = self.get_page_by_name(args.pop(0))
813 # we keep track of current page to check subpage
814 current_page = root
815 url_elts = []
816 arguments = []
817 while True:
818 while args and not isinstance(args[0], SubPage):
819 arguments.append(quote(args.pop(0)))
820 if not url_elts:
821 url_elts.append(root.get_url(*arguments))
822 else:
823 url_elts.extend(arguments)
824 if not args:
825 break
826 else:
827 path, current_page = current_page.get_sub_page_by_name(args.pop(0))
828 arguments = [path]
829 return self.host.check_redirection(self.vhost_root, "/".join(url_elts))
830
831 def getChildWithDefault(self, path, request):
832 # we handle children ourselves
833 raise exceptions.InternalError(
834 "this method should not be used with LiberviaPage"
835 )
836
837 def next_path(self, request):
838 """get next URL path segment, and update request accordingly
839
840 will move first segment of postpath in prepath
841 @param request(server.Request): current HTTP request
842 @return (unicode): unquoted segment
843 @raise IndexError: there is no segment left
844 """
845 pathElement = request.postpath.pop(0)
846 request.prepath.append(pathElement)
847 return urllib.parse.unquote(pathElement.decode('utf-8'))
848
849 def _filter_path_value(self, value, handler, name, request):
850 """Modify a path value according to handler (see [get_path_args])"""
851 if handler in ("@", "@jid") and value == "@":
852 value = None
853
854 if handler in ("", "@"):
855 if value is None:
856 return ""
857 elif handler in ("jid", "@jid"):
858 if value:
859 try:
860 return jid.JID(value)
861 except (RuntimeError, jid.InvalidFormat):
862 log.warning(_("invalid jid argument: {value}").format(value=value))
863 self.page_error(request, C.HTTP_BAD_REQUEST)
864 else:
865 return ""
866 else:
867 return handler(self, value, name, request)
868
869 return value
870
871 def get_path_args(self, request, names, min_args=0, **kwargs):
872 """get several path arguments at once
873
874 Arguments will be put in request data.
875 Missing arguments will have None value
876 @param names(list[unicode]): list of arguments to get
877 @param min_args(int): if less than min_args are found, PageError is used with
878 C.HTTP_BAD_REQUEST
879 Use 0 to ignore
880 @param **kwargs: special value or optional callback to use for arguments
881 names of the arguments must correspond to those in names
882 special values may be:
883 - '': use empty string instead of None when no value is specified
884 - '@': if value of argument is empty or '@', empty string will be used
885 - 'jid': value must be converted to jid.JID if it exists, else empty
886 string is used
887 - '@jid': if value of arguments is empty or '@', empty string will be
888 used, else it will be converted to jid
889 """
890 data = self.get_r_data(request)
891
892 for idx, name in enumerate(names):
893 if name[0] == "*":
894 value = data[name[1:]] = []
895 while True:
896 try:
897 value.append(self.next_path(request))
898 except IndexError:
899 idx -= 1
900 break
901 else:
902 idx += 1
903 else:
904 try:
905 value = data[name] = self.next_path(request)
906 except IndexError:
907 data[name] = None
908 idx -= 1
909 break
910
911 values_count = idx + 1
912 if values_count < min_args:
913 log.warning(_("Missing arguments in URL (got {count}, expected at least "
914 "{min_args})").format(count=values_count, min_args=min_args))
915 self.page_error(request, C.HTTP_BAD_REQUEST)
916
917 for name in names[values_count:]:
918 data[name] = None
919
920 for name, handler in kwargs.items():
921 if name[0] == "*":
922 data[name] = [
923 self._filter_path_value(v, handler, name, request) for v in data[name]
924 ]
925 else:
926 data[name] = self._filter_path_value(data[name], handler, name, request)
927
928 ## Pagination/Filtering ##
929
930 def get_pubsub_extra(self, request, page_max=10, params=None, extra=None,
931 order_by=C.ORDER_BY_CREATION):
932 """Set extra dict to retrieve PubSub items corresponding to URL parameters
933
934 Following parameters are used:
935 - after: set rsm_after with ID of item
936 - before: set rsm_before with ID of item
937 @param request(server.Request): current HTTP request
938 @param page_max(int): required number of items per page
939 @param params(None, dict[unicode, list[unicode]]): params as returned by
940 self.get_all_posted_data.
941 None to parse URL automatically
942 @param extra(None, dict): extra dict to use, or None to use a new one
943 @param order_by(unicode, None): key to order by
944 None to not specify order
945 @return (dict): fill extra data
946 """
947 if params is None:
948 params = self.get_all_posted_data(request, multiple=False)
949 if extra is None:
950 extra = {}
951 else:
952 assert not {"rsm_max", "rsm_after", "rsm_before",
953 C.KEY_ORDER_BY}.intersection(list(extra.keys()))
954 extra["rsm_max"] = params.get("page_max", str(page_max))
955 if order_by is not None:
956 extra[C.KEY_ORDER_BY] = order_by
957 if 'after' in params:
958 extra['rsm_after'] = params['after']
959 elif 'before' in params:
960 extra['rsm_before'] = params['before']
961 else:
962 # RSM returns list in order (oldest first), but we want most recent first
963 # so we start by the end
964 extra['rsm_before'] = ""
965 return extra
966
967 def set_pagination(self, request: server.Request, pubsub_data: dict) -> None:
968 """Add to template_data if suitable
969
970 "previous_page_url" and "next_page_url" will be added using respectively
971 "before" and "after" URL parameters
972 @param request: current HTTP request
973 @param pubsub_data: pubsub metadata
974 """
975 template_data = request.template_data
976 extra = {}
977 try:
978 rsm = pubsub_data["rsm"]
979 last_id = rsm["last"]
980 except KeyError:
981 # no pagination available
982 return
983
984 # if we have a search query, we must keep it
985 search = self.get_posted_data(request, 'search', raise_on_missing=False)
986 if search is not None:
987 extra['search'] = search.strip()
988
989 # same for page_max
990 page_max = self.get_posted_data(request, 'page_max', raise_on_missing=False)
991 if page_max is not None:
992 extra['page_max'] = page_max
993
994 if rsm.get("index", 1) > 0:
995 # We only show previous button if it's not the first page already.
996 # If we have no index, we default to display the button anyway
997 # as we can't know if we are on the first page or not.
998 first_id = rsm["first"]
999 template_data['previous_page_url'] = self.get_param_url(
1000 request, before=first_id, **extra)
1001 if not pubsub_data["complete"]:
1002 # we also show the page next button if complete is None because we
1003 # can't know where we are in the feed in this case.
1004 template_data['next_page_url'] = self.get_param_url(
1005 request, after=last_id, **extra)
1006
1007
1008 ## Cache handling ##
1009
1010 def _set_cache_headers(self, request, cache):
1011 """Set ETag and Last-Modified HTTP headers, used for caching"""
1012 request.setHeader("ETag", cache.hash)
1013 last_modified = self.host.get_http_date(cache.created)
1014 request.setHeader("Last-Modified", last_modified)
1015
1016 def _check_cache_headers(self, request, cache):
1017 """Check if a cache condition is set on the request
1018
1019 if condition is valid, C.HTTP_NOT_MODIFIED is returned
1020 """
1021 etag_match = request.getHeader("If-None-Match")
1022 if etag_match is not None:
1023 if cache.hash == etag_match:
1024 self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True)
1025 else:
1026 modified_match = request.getHeader("If-Modified-Since")
1027 if modified_match is not None:
1028 modified = date_utils.date_parse(modified_match)
1029 if modified >= int(cache.created):
1030 self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True)
1031
1032 def check_cache_subscribe_cb(self, sub_id, service, node):
1033 self.cache_pubsub_sub.add((service, node, sub_id))
1034
1035 def check_cache_subscribe_eb(self, failure_, service, node):
1036 log.warning(_("Can't subscribe to node: {msg}").format(msg=failure_))
1037 # FIXME: cache must be marked as unusable here
1038
1039 def ps_node_watch_add_eb(self, failure_, service, node):
1040 log.warning(_("Can't add node watched: {msg}").format(msg=failure_))
1041
1042 def use_cache(self, request: server.Request) -> bool:
1043 """Indicate if the cache should be used
1044
1045 test request header to see if it is requested to skip the cache
1046 @return: True if cache should be used
1047 """
1048 return request.getHeader('cache-control') != 'no-cache'
1049
1050 def check_cache(self, request, cache_type, **kwargs):
1051 """check if a page is in cache and return cached version if suitable
1052
1053 this method may perform extra operation to handle cache (e.g. subscribing to a
1054 pubsub node)
1055 @param request(server.Request): current HTTP request
1056 @param cache_type(int): on of C.CACHE_* const.
1057 @param **kwargs: args according to cache_type:
1058 C.CACHE_PUBSUB:
1059 service: pubsub service
1060 node: pubsub node
1061 short: short name of feature (needed if node is empty to find namespace)
1062
1063 """
1064 if request.postpath:
1065 #  we are not on the final page, no need to go further
1066 return
1067
1068 if request.uri != request.path:
1069 # we don't cache page with query arguments as there can be a lot of variants
1070 # influencing page results (e.g. search terms)
1071 log.debug("ignoring cache due to query arguments")
1072
1073 no_cache = not self.use_cache(request)
1074
1075 profile = self.get_profile(request) or C.SERVICE_PROFILE
1076
1077 if cache_type == C.CACHE_PUBSUB:
1078 service, node = kwargs["service"], kwargs["node"]
1079 if not node:
1080 try:
1081 short = kwargs["short"]
1082 node = self.host.ns_map[short]
1083 except KeyError:
1084 log.warning(_('Can\'t use cache for empty node without namespace '
1085 'set, please ensure to set "short" and that it is '
1086 'registered'))
1087 return
1088 if profile != C.SERVICE_PROFILE:
1089 #  only service profile is cached for now
1090 return
1091 session_data = self.host.get_session_data(request, session_iface.IWebSession)
1092 locale = session_data.locale
1093 if locale == C.DEFAULT_LOCALE:
1094 # no need to duplicate cache here
1095 locale = None
1096 try:
1097 cache = (self.cache[profile][cache_type][service][node]
1098 [self.vhost_root][request.uri][locale][self])
1099 except KeyError:
1100 # no cache yet, let's subscribe to the pubsub node
1101 d1 = self.host.bridge_call(
1102 "ps_subscribe", service.full(), node, "", profile
1103 )
1104 d1.addCallback(self.check_cache_subscribe_cb, service, node)
1105 d1.addErrback(self.check_cache_subscribe_eb, service, node)
1106 d2 = self.host.bridge_call("ps_node_watch_add", service.full(), node, profile)
1107 d2.addErrback(self.ps_node_watch_add_eb, service, node)
1108 self._do_cache = [self, profile, cache_type, service, node,
1109 self.vhost_root, request.uri, locale]
1110 #  we don't return the Deferreds as it is not needed to wait for
1111 # the subscription to continue with page rendering
1112 return
1113 else:
1114 if no_cache:
1115 del (self.cache[profile][cache_type][service][node]
1116 [self.vhost_root][request.uri][locale][self])
1117 log.debug(f"cache removed for {self}")
1118 return
1119
1120 else:
1121 raise exceptions.InternalError("Unknown cache_type")
1122 log.debug("using cache for {page}".format(page=self))
1123 cache.last_access = time.time()
1124 self._set_cache_headers(request, cache)
1125 self._check_cache_headers(request, cache)
1126 request.write(cache.rendered)
1127 request.finish()
1128 raise failure.Failure(exceptions.CancelError("cache is used"))
1129
1130 def _cache_url(self, request, profile):
1131 self.cached_urls.setdefault(profile, {})[request.uri] = CacheURL(request)
1132
1133 @classmethod
1134 def on_node_event(cls, host, service, node, event_type, items, profile):
1135 """Invalidate cache for all pages linked to this node"""
1136 try:
1137 cache = cls.cache[profile][C.CACHE_PUBSUB][jid.JID(service)][node]
1138 except KeyError:
1139 log.info(_(
1140 "Removing subscription for {service}/{node}: "
1141 "the page is not cached").format(service=service, node=node))
1142 d1 = host.bridge_call("ps_unsubscribe", service, node, profile)
1143 d1.addErrback(
1144 lambda failure_: log.warning(
1145 _("Can't unsubscribe from {service}/{node}: {msg}").format(
1146 service=service, node=node, msg=failure_)))
1147 d2 = host.bridge_call("ps_node_watch_add", service, node, profile)
1148 # TODO: check why the page is not in cache, remove subscription?
1149 d2.addErrback(
1150 lambda failure_: log.warning(
1151 _("Can't remove watch for {service}/{node}: {msg}").format(
1152 service=service, node=node, msg=failure_)))
1153 else:
1154 cache.clear()
1155
1156 # identities
1157
1158 async def fill_missing_identities(
1159 self,
1160 request: server.Request,
1161 entities: List[Union[str, jid.JID, None]],
1162 ) -> None:
1163 """Check if all entities have an identity cache, get missing ones from backend
1164
1165 @param request: request with a plugged profile
1166 @param entities: entities to check, None or empty strings will be filtered
1167 """
1168 entities = {str(e) for e in entities if e}
1169 profile = self.get_profile(request) or C.SERVICE_PROFILE
1170 identities = self.host.get_session_data(
1171 request,
1172 session_iface.IWebSession
1173 ).identities
1174 for e in entities:
1175 if e not in identities:
1176 id_raw = await self.host.bridge_call(
1177 'identity_get', e, [], True, profile)
1178 identities[e] = data_format.deserialise(id_raw)
1179
1180 # signals, server => browser communication
1181
1182 def delegate_to_resource(self, request, resource):
1183 """continue workflow with Twisted Resource"""
1184 buf = resource.render(request)
1185 if buf == server.NOT_DONE_YET:
1186 pass
1187 else:
1188 request.write(buf)
1189 request.finish()
1190 raise failure.Failure(exceptions.CancelError("resource delegation"))
1191
1192 def http_redirect(self, request, url):
1193 """redirect to an URL using HTTP redirection
1194
1195 @param request(server.Request): current HTTP request
1196 @param url(unicode): url to redirect to
1197 """
1198 web_util.redirectTo(url.encode("utf-8"), request)
1199 request.finish()
1200 raise failure.Failure(exceptions.CancelError("HTTP redirection is used"))
1201
1202 def redirect_or_continue(self, request, redirect_arg="redirect_url"):
1203 """Helper method to redirect a page to an url given as arg
1204
1205 if the arg is not present, the page will continue normal workflow
1206 @param request(server.Request): current HTTP request
1207 @param redirect_arg(unicode): argument to use to get redirection URL
1208 @interrupt: redirect the page to requested URL
1209 @interrupt page_error(C.HTTP_BAD_REQUEST): empty or non local URL is used
1210 """
1211 redirect_arg = redirect_arg.encode('utf-8')
1212 try:
1213 url = request.args[redirect_arg][0].decode('utf-8')
1214 except (KeyError, IndexError):
1215 pass
1216 else:
1217 #  a redirection is requested
1218 if not url or url[0] != "/":
1219 # we only want local urls
1220 self.page_error(request, C.HTTP_BAD_REQUEST)
1221 else:
1222 self.http_redirect(request, url)
1223
1224 def page_redirect(self, page_path, request, skip_parse_url=True, path_args=None):
1225 """redirect a page to a named page
1226
1227 the workflow will continue with the workflow of the named page,
1228 skipping named page's parse_url method if it exist.
1229 If you want to do a HTTP redirection, use http_redirect
1230 @param page_path(unicode): path to page (elements are separated by "/"):
1231 if path starts with a "/":
1232 path is a full path starting from root
1233 else:
1234 - first element is name as registered in name variable
1235 - following element are subpages path
1236 e.g.: "blog" redirect to page named "blog"
1237 "blog/atom.xml" redirect to atom.xml subpage of "blog"
1238 "/common/blog/atom.xml" redirect to the page at the given full path
1239 @param request(server.Request): current HTTP request
1240 @param skip_parse_url(bool): if True, parse_url method on redirect page will be
1241 skipped
1242 @param path_args(list[unicode], None): path arguments to use in redirected page
1243 @raise KeyError: there is no known page with this name
1244 """
1245 # FIXME: render non LiberviaPage resources
1246 path = page_path.rstrip("/").split("/")
1247 if not path[0]:
1248 redirect_page = self.vhost_root
1249 else:
1250 redirect_page = self.named_pages[path[0]]
1251
1252 for subpage in path[1:]:
1253 subpage = subpage.encode('utf-8')
1254 if redirect_page is self.vhost_root:
1255 redirect_page = redirect_page.children[subpage]
1256 else:
1257 redirect_page = redirect_page.original.children[subpage]
1258
1259 if path_args is not None:
1260 args = [quote(a).encode() for a in path_args]
1261 request.postpath = args + request.postpath
1262
1263 if self._do_cache:
1264 # if cache is needed, it will be handled by final page
1265 redirect_page._do_cache = self._do_cache
1266 self._do_cache = None
1267
1268 defer.ensureDeferred(
1269 redirect_page.render_page(request, skip_parse_url=skip_parse_url)
1270 )
1271 raise failure.Failure(exceptions.CancelError("page redirection is used"))
1272
1273 def page_error(self, request, code=C.HTTP_NOT_FOUND, no_body=False):
1274 """generate an error page and terminate the request
1275
1276 @param request(server.Request): HTTP request
1277 @param core(int): error code to use
1278 @param no_body: don't write body if True
1279 """
1280 if self._do_cache is not None:
1281 # we don't want to cache error pages
1282 self._do_cache = None
1283 request.setResponseCode(code)
1284 if no_body:
1285 request.finish()
1286 else:
1287 template = "error/" + str(code) + ".html"
1288 template_data = request.template_data
1289 session_data = self.host.get_session_data(request, session_iface.IWebSession)
1290 if session_data.locale is not None:
1291 template_data['locale'] = session_data.locale
1292 if self.vhost_root.site_name:
1293 template_data['site'] = self.vhost_root.site_name
1294
1295 rendered = self.host.renderer.render(
1296 template,
1297 theme=session_data.theme or self.default_theme,
1298 media_path=f"/{C.MEDIA_DIR}",
1299 build_path=f"/{C.BUILD_DIR}/",
1300 site_themes=self.site_themes,
1301 error_code=code,
1302 **template_data
1303 )
1304
1305 self.write_data(rendered, request)
1306 raise failure.Failure(exceptions.CancelError("error page is used"))
1307
1308 def write_data(self, data, request):
1309 """write data to transport and finish the request"""
1310 if data is None:
1311 self.page_error(request)
1312 data_encoded = data.encode("utf-8")
1313
1314 if self._do_cache is not None:
1315 redirected_page = self._do_cache.pop(0)
1316 cache = reduce(lambda d, k: d.setdefault(k, {}), self._do_cache, self.cache)
1317 page_cache = cache[redirected_page] = CachePage(data_encoded)
1318 self._set_cache_headers(request, page_cache)
1319 log.debug(_("{page} put in cache for [{profile}]")
1320 .format( page=self, profile=self._do_cache[0]))
1321 self._do_cache = None
1322 self._check_cache_headers(request, page_cache)
1323
1324 try:
1325 request.write(data_encoded)
1326 except AttributeError:
1327 log.warning(_("Can't write page, the request has probably been cancelled "
1328 "(browser tab closed or reloaded)"))
1329 return
1330 request.finish()
1331
1332 def _subpages_handler(self, request):
1333 """render subpage if suitable
1334
1335 this method checks if there is still an unmanaged part of the path
1336 and check if it corresponds to a subpage. If so, it render the subpage
1337 else it render a NoResource.
1338 If there is no unmanaged part of the segment, current page workflow is pursued
1339 """
1340 if request.postpath:
1341 subpage = self.next_path(request).encode('utf-8')
1342 try:
1343 child = self.children[subpage]
1344 except KeyError:
1345 self.page_error(request)
1346 else:
1347 child.render(request)
1348 raise failure.Failure(exceptions.CancelError("subpage page is used"))
1349
1350 def _prepare_dynamic(self, request):
1351 session_data = self.host.get_session_data(request, session_iface.IWebSession)
1352 # we need to activate dynamic page
1353 # we set data for template, and create/register token
1354 # socket_token = str(uuid.uuid4())
1355 socket_url = self.host.get_websocket_url(request)
1356 # as for CSRF, it is important to not let the socket token if we use the service
1357 # profile, as those pages can be cached, and then the token leaked.
1358 socket_token = '' if session_data.profile is None else session_data.ws_token
1359 socket_debug = C.bool_const(self.host.debug)
1360 request.template_data["websocket"] = WebsocketMeta(
1361 socket_url, socket_token, socket_debug
1362 )
1363 # we will keep track of handlers to remove
1364 request._signals_registered = []
1365 # we will cache registered signals until socket is opened
1366 request._signals_cache = []
1367
1368 def _render_template(self, request):
1369 template_data = request.template_data
1370
1371 # if confirm variable is set in case of successfuly data post
1372 session_data = self.host.get_session_data(request, session_iface.IWebSession)
1373 template_data['identities'] = session_data.identities
1374 if session_data.pop_page_flag(self, C.FLAG_CONFIRM):
1375 template_data["confirm"] = True
1376 notifs = session_data.pop_page_notifications(self)
1377 if notifs:
1378 template_data["notifications"] = notifs
1379 if session_data.jid is not None:
1380 template_data["own_jid"] = session_data.jid
1381 if session_data.locale is not None:
1382 template_data['locale'] = session_data.locale
1383 if session_data.guest:
1384 template_data['guest_session'] = True
1385 if self.vhost_root.site_name:
1386 template_data['site'] = self.vhost_root.site_name
1387 if self.dyn_data:
1388 for data in self.dyn_data.values():
1389 try:
1390 scripts = data['scripts']
1391 except KeyError:
1392 pass
1393 else:
1394 template_data.setdefault('scripts', utils.OrderedSet()).update(scripts)
1395 template_data.update(data.get('template', {}))
1396 data_common = self.vhost_root.dyn_data_common
1397 common_scripts = data_common['scripts']
1398 if common_scripts:
1399 template_data.setdefault('scripts', utils.OrderedSet()).update(common_scripts)
1400 if "template" in data_common:
1401 for key, value in data_common["template"].items():
1402 if key not in template_data:
1403 template_data[key] = value
1404
1405 theme = session_data.theme or self.default_theme
1406 self.expose_to_scripts(
1407 request,
1408 cache_path=session_data.cache_dir,
1409 templates_root_url=str(self.vhost_root.get_front_url(theme)),
1410 profile=session_data.profile)
1411
1412 uri = request.uri.decode()
1413 try:
1414 template_data["current_page"] = next(
1415 m[0] for m in self.main_menu if uri.startswith(m[1])
1416 )
1417 except StopIteration:
1418 pass
1419
1420 return self.host.renderer.render(
1421 self.template,
1422 theme=theme,
1423 site_themes=self.site_themes,
1424 page_url=self.get_url(),
1425 media_path=f"/{C.MEDIA_DIR}",
1426 build_path=f"/{C.BUILD_DIR}/",
1427 cache_path=session_data.cache_dir,
1428 main_menu=self.main_menu,
1429 **template_data)
1430
1431 def _on_data_post_redirect(self, ret, request):
1432 """called when page's on_data_post has been done successfuly
1433
1434 This will do a Post/Redirect/Get pattern.
1435 this method redirect to the same page or to request.data['post_redirect_page']
1436 post_redirect_page can be either a page or a tuple with page as first item, then
1437 a list of unicode arguments to append to the url.
1438 if post_redirect_page is not used, initial request.uri (i.e. the same page as
1439 where the data have been posted) will be used for redirection.
1440 HTTP status code "See Other" (303) is used as it is the recommanded code in
1441 this case.
1442 @param ret(None, unicode, iterable): on_data_post return value
1443 see LiberviaPage.__init__ on_data_post docstring
1444 """
1445 if ret is None:
1446 ret = ()
1447 elif isinstance(ret, str):
1448 ret = (ret,)
1449 else:
1450 ret = tuple(ret)
1451 raise NotImplementedError(
1452 _("iterable in on_data_post return value is not used yet")
1453 )
1454 session_data = self.host.get_session_data(request, session_iface.IWebSession)
1455 request_data = self.get_r_data(request)
1456 if "post_redirect_page" in request_data:
1457 redirect_page_data = request_data["post_redirect_page"]
1458 if isinstance(redirect_page_data, tuple):
1459 redirect_page = redirect_page_data[0]
1460 redirect_page_args = redirect_page_data[1:]
1461 redirect_uri = redirect_page.get_url(*redirect_page_args)
1462 else:
1463 redirect_page = redirect_page_data
1464 redirect_uri = redirect_page.url
1465 else:
1466 redirect_page = self
1467 redirect_uri = request.uri
1468
1469 if not C.POST_NO_CONFIRM in ret:
1470 session_data.set_page_flag(redirect_page, C.FLAG_CONFIRM)
1471 request.setResponseCode(C.HTTP_SEE_OTHER)
1472 request.setHeader(b"location", redirect_uri)
1473 request.finish()
1474 raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used"))
1475
1476 async def _on_data_post(self, request):
1477 self.check_csrf(request)
1478 try:
1479 ret = await as_deferred(self.on_data_post, self, request)
1480 except exceptions.DataError as e:
1481 # something is wrong with the posted data, we re-display the page with a
1482 # warning notification
1483 session_data = self.host.get_session_data(request, session_iface.IWebSession)
1484 session_data.set_page_notification(self, str(e), C.LVL_WARNING)
1485 request.setResponseCode(C.HTTP_SEE_OTHER)
1486 request.setHeader("location", request.uri)
1487 request.finish()
1488 raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used"))
1489 else:
1490 if ret != "continue":
1491 self._on_data_post_redirect(ret, request)
1492
1493 def get_posted_data(
1494 self,
1495 request: server.Request,
1496 keys,
1497 multiple: bool = False,
1498 raise_on_missing: bool = True,
1499 strip: bool = True
1500 ):
1501 """Get data from a POST request or from URL's query part and decode it
1502
1503 @param request: request linked to the session
1504 @param keys(unicode, iterable[unicode]): name of the value(s) to get
1505 unicode to get one value
1506 iterable to get more than one
1507 @param multiple: True if multiple values are possible/expected
1508 if False, the first value is returned
1509 @param raise_on_missing: raise KeyError on missing key if True
1510 else use None for missing values
1511 @param strip: if True, apply "strip()" on values
1512 @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]):
1513 values received for this(these) key(s)
1514 @raise KeyError: one specific key has been requested, and it is missing
1515 """
1516 #  FIXME: request.args is already unquoting the value, it seems we are doing
1517 # double unquote
1518 if isinstance(keys, str):
1519 keys = [keys]
1520
1521 keys = [k.encode('utf-8') for k in keys]
1522
1523 ret = []
1524 for key in keys:
1525 gen = (urllib.parse.unquote(v.decode("utf-8"))
1526 for v in request.args.get(key, []))
1527 if multiple:
1528 ret.append(gen.strip() if strip else gen)
1529 else:
1530 try:
1531 v = next(gen)
1532 except StopIteration:
1533 if raise_on_missing:
1534 raise KeyError(key)
1535 else:
1536 ret.append(None)
1537 else:
1538 ret.append(v.strip() if strip else v)
1539
1540 if len(keys) == 1:
1541 return ret[0]
1542 else:
1543 return ret
1544
1545 def get_all_posted_data(self, request, except_=(), multiple=True):
1546 """get all posted data
1547
1548 @param request(server.Request): request linked to the session
1549 @param except_(iterable[unicode]): key of values to ignore
1550 csrf_token will always be ignored
1551 @param multiple(bool): if False, only the first values are returned
1552 @return (dict[unicode, list[unicode]]): post values
1553 """
1554 except_ = tuple(except_) + ("csrf_token",)
1555 ret = {}
1556 for key, values in request.args.items():
1557 key = key.decode('utf-8')
1558 key = urllib.parse.unquote(key)
1559 if key in except_:
1560 continue
1561 values = [v.decode('utf-8') for v in values]
1562 if not multiple:
1563 ret[key] = urllib.parse.unquote(values[0])
1564 else:
1565 ret[key] = [urllib.parse.unquote(v) for v in values]
1566 return ret
1567
1568 def get_profile(self, request):
1569 """Helper method to easily get current profile
1570
1571 @return (unicode, None): current profile
1572 None if no profile session is started
1573 """
1574 web_session = self.host.get_session_data(request, session_iface.IWebSession)
1575 return web_session.profile
1576
1577 def get_jid(self, request):
1578 """Helper method to easily get current jid
1579
1580 @return: current jid
1581 """
1582 web_session = self.host.get_session_data(request, session_iface.IWebSession)
1583 return web_session.jid
1584
1585
1586 def get_r_data(self, request):
1587 """Helper method to get request data dict
1588
1589 this dictionnary if for the request only, it is not saved in session
1590 It is mainly used to pass data between pages/methods called during request
1591 workflow
1592 @return (dict): request data
1593 """
1594 try:
1595 return request.data
1596 except AttributeError:
1597 request.data = {}
1598 return request.data
1599
1600 def get_page_data(self, request, key):
1601 """Helper method to retrieve reload resistant data"""
1602 web_session = self.host.get_session_data(request, session_iface.IWebSession)
1603 return web_session.get_page_data(self, key)
1604
1605 def set_page_data(self, request, key, value):
1606 """Helper method to set reload resistant data"""
1607 web_session = self.host.get_session_data(request, session_iface.IWebSession)
1608 return web_session.set_page_data(self, key, value)
1609
1610 def handle_search(self, request, extra):
1611 """Manage Full-Text Search
1612
1613 Check if "search" query argument is present, and add MAM filter for it if
1614 necessary.
1615 If used, the "search" variable will also be available in template data, thus
1616 frontend can display some information about it.
1617 """
1618 search = self.get_posted_data(request, 'search', raise_on_missing=False)
1619 if search is not None:
1620 search = search.strip()
1621 if search:
1622 try:
1623 extra[f'mam_filter_{self.host.ns_map["fulltextmam"]}'] = search
1624 except KeyError:
1625 log.warning(_("Full-text search is not available"))
1626 else:
1627 request.template_data['search'] = search
1628
1629 def _check_access(self, request):
1630 """Check access according to self.access
1631
1632 if access is not granted, show a HTTP_FORBIDDEN page_error and stop request,
1633 else return data (so it can be inserted in deferred chain
1634 """
1635 if self.access == C.PAGES_ACCESS_PUBLIC:
1636 pass
1637 elif self.access == C.PAGES_ACCESS_PROFILE:
1638 profile = self.get_profile(request)
1639 if not profile:
1640 # registration allowed, we redirect to login page
1641 login_url = self.get_page_redirect_url(request)
1642 self.http_redirect(request, login_url)
1643
1644 def set_best_locale(self, request):
1645 """Guess the best locale when it is not specified explicitly by user
1646
1647 This method will check "accept-language" header, and set locale to first
1648 matching value with available translations.
1649 """
1650 accept_language = request.getHeader("accept-language")
1651 if not accept_language:
1652 return
1653 accepted = [a.strip() for a in accept_language.split(',')]
1654 available = [str(l) for l in self.host.renderer.translations]
1655 for lang in accepted:
1656 lang = lang.split(';')[0].strip().lower()
1657 if not lang:
1658 continue
1659 for a in available:
1660 if a.lower().startswith(lang):
1661 session_data = self.host.get_session_data(request,
1662 session_iface.IWebSession)
1663 session_data.locale = a
1664 return
1665
1666 async def render_page(self, request, skip_parse_url=False):
1667 """Main method to handle the workflow of a LiberviaPage"""
1668 # template_data are the variables passed to template
1669 if not hasattr(request, "template_data"):
1670 # if template_data doesn't exist, it's the beginning of the request workflow
1671 # so we fill essential data
1672 session_data = self.host.get_session_data(request, session_iface.IWebSession)
1673 profile = session_data.profile
1674 request.template_data = {
1675 "profile": profile,
1676 # it's important to not add CSRF token and session uuid if service profile
1677 # is used because the page may be cached, and the token then leaked
1678 "csrf_token": "" if profile is None else session_data.csrf_token,
1679 "session_uuid": "public" if profile is None else session_data.uuid,
1680 "breadcrumbs": []
1681 }
1682
1683 # XXX: here is the code which need to be executed once
1684 # at the beginning of the request hanling
1685 if request.postpath and not request.postpath[-1]:
1686 # we don't differenciate URLs finishing with '/' or not
1687 del request.postpath[-1]
1688
1689 # i18n
1690 key_lang = C.KEY_LANG.encode()
1691 if key_lang in request.args:
1692 try:
1693 locale = request.args.pop(key_lang)[0].decode()
1694 except IndexError:
1695 log.warning("empty lang received")
1696 else:
1697 if "/" in locale:
1698 # "/" is refused because locale may sometime be used to access
1699 # path, if localised documents are available for instance
1700 log.warning(_('illegal char found in locale ("/"), hack '
1701 'attempt? locale={locale}').format(locale=locale))
1702 locale = None
1703 session_data.locale = locale
1704
1705 # if locale is not specified, we try to find one requested by browser
1706 if session_data.locale is None:
1707 self.set_best_locale(request)
1708
1709 # theme
1710 key_theme = C.KEY_THEME.encode()
1711 if key_theme in request.args:
1712 theme = request.args.pop(key_theme)[0].decode()
1713 if key_theme != session_data.theme:
1714 if theme not in self.site_themes:
1715 log.warning(_(
1716 "Theme {theme!r} doesn't exist for {vhost}"
1717 .format(theme=theme, vhost=self.vhost_root)))
1718 else:
1719 session_data.theme = theme
1720 try:
1721
1722 try:
1723 self._check_access(request)
1724
1725 if self.redirect is not None:
1726 self.page_redirect(self.redirect, request, skip_parse_url=False)
1727
1728 if self.parse_url is not None and not skip_parse_url:
1729 if self.url_cache:
1730 profile = self.get_profile(request)
1731 try:
1732 cache_url = self.cached_urls[profile][request.uri]
1733 except KeyError:
1734 # no cache for this URI yet
1735 #  we do normal URL parsing, and then the cache
1736 await as_deferred(self.parse_url, self, request)
1737 self._cache_url(request, profile)
1738 else:
1739 log.debug(f"using URI cache for {self}")
1740 cache_url.use(request)
1741 else:
1742 await as_deferred(self.parse_url, self, request)
1743
1744 if self.add_breadcrumb is None:
1745 label = (
1746 self.label
1747 or self.name
1748 or self.url[self.url.rfind('/')+1:]
1749 )
1750 breadcrumb = {
1751 "url": self.url,
1752 "label": label.title(),
1753 }
1754 request.template_data["breadcrumbs"].append(breadcrumb)
1755 else:
1756 await as_deferred(
1757 self.add_breadcrumb,
1758 self,
1759 request,
1760 request.template_data["breadcrumbs"]
1761 )
1762
1763 self._subpages_handler(request)
1764
1765 if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST):
1766 # only HTTP GET and POST are handled so far
1767 self.page_error(request, C.HTTP_BAD_REQUEST)
1768
1769 if request.method == C.HTTP_METHOD_POST:
1770 if self.on_data_post == 'continue':
1771 pass
1772 elif self.on_data_post is None:
1773 # if we don't have on_data_post, the page was not expecting POST
1774 # so we return an error
1775 self.page_error(request, C.HTTP_BAD_REQUEST)
1776 else:
1777 await self._on_data_post(request)
1778 # by default, POST follow normal behaviour after on_data_post is called
1779 # this can be changed by a redirection or other method call in on_data_post
1780
1781 if self.dynamic:
1782 self._prepare_dynamic(request)
1783
1784 if self.prepare_render:
1785 await as_deferred(self.prepare_render, self, request)
1786
1787 if self.template:
1788 rendered = self._render_template(request)
1789 elif self.render_method:
1790 rendered = await as_deferred(self.render_method, self, request)
1791 else:
1792 raise exceptions.InternalError(
1793 "No method set to render page, please set a template or use a "
1794 "render method"
1795 )
1796
1797 self.write_data(rendered, request)
1798
1799 except failure.Failure as f:
1800 # we have to unpack the Failure to catch the right Exception
1801 raise f.value
1802
1803 except exceptions.CancelError:
1804 pass
1805 except BridgeException as e:
1806 if e.condition == 'not-allowed':
1807 log.warning("not allowed exception catched")
1808 self.page_error(request, C.HTTP_FORBIDDEN)
1809 elif e.condition == 'item-not-found' or e.classname == 'NotFound':
1810 self.page_error(request, C.HTTP_NOT_FOUND)
1811 elif e.condition == 'remote-server-not-found':
1812 self.page_error(request, C.HTTP_NOT_FOUND)
1813 elif e.condition == 'forbidden':
1814 if self.get_profile(request) is None:
1815 log.debug("access forbidden, we're redirecting to log-in page")
1816 self.http_redirect(request, self.get_page_redirect_url(request))
1817 else:
1818 self.page_error(request, C.HTTP_FORBIDDEN)
1819 else:
1820 log.error(
1821 _("Uncatched bridge exception for HTTP request on {url}: {e}\n"
1822 "page name: {name}\npath: {path}\nURL: {full_url}\n{tb}")
1823 .format(
1824 url=self.url,
1825 e=e,
1826 name=self.name or "",
1827 path=self.root_dir,
1828 full_url=request.URLPath(),
1829 tb=traceback.format_exc(),
1830 )
1831 )
1832 try:
1833 self.page_error(request, C.HTTP_INTERNAL_ERROR)
1834 except exceptions.CancelError:
1835 pass
1836 except Exception as e:
1837 log.error(
1838 _("Uncatched error for HTTP request on {url}: {e}\npage name: "
1839 "{name}\npath: {path}\nURL: {full_url}\n{tb}")
1840 .format(
1841 url=self.url,
1842 e=e,
1843 name=self.name or "",
1844 path=self.root_dir,
1845 full_url=request.URLPath(),
1846 tb=traceback.format_exc(),
1847 )
1848 )
1849 try:
1850 self.page_error(request, C.HTTP_INTERNAL_ERROR)
1851 except exceptions.CancelError:
1852 pass
1853
1854 def render_GET(self, request):
1855 defer.ensureDeferred(self.render_page(request))
1856 return server.NOT_DONE_YET
1857
1858 def render_POST(self, request):
1859 defer.ensureDeferred(self.render_page(request))
1860 return server.NOT_DONE_YET