changeset 1483:595e7fef41f3

merge bookmark @
author Goffi <goffi@goffi.org>
date Fri, 12 Nov 2021 17:48:30 +0100
parents fc91b78b71db (current diff) e35151a2cec1 (diff)
children 6643855770a5
files libervia/VERSION libervia/pages/blog/view/page_meta.py libervia/pages/lists/view/page_meta.py libervia/pages/lists/view_item/page_meta.py libervia/server/server.py
diffstat 14 files changed, 431 insertions(+), 252 deletions(-) [+]
line wrap: on
line diff
--- a/.hgtags	Tue Sep 28 18:18:37 2021 +0200
+++ b/.hgtags	Fri Nov 12 17:48:30 2021 +0100
@@ -12,3 +12,4 @@
 cc16d93d4181fb5d3ee2de349b3b7b7c52fcaddf 0.7.0b1
 889e8d07e95747205b4aec20a34d7dadc7f4068c 0.7.0
 3821168b945985e2878a2dc4c6abbb5211bcc1c7 v0.8.0b1
+f18767efabccd4b15265bf725b7603b7220148e5 v0.8.0b1.post1
--- a/libervia/pages/_browser/bridge.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/pages/_browser/bridge.py	Fri Nov 12 17:48:30 2021 +0100
@@ -23,7 +23,7 @@
         else:
             print(f"bridge called failed: code: {xhr.response}, text: {xhr.statusText}")
             if errback is not None:
-                errback({"fullname": "InternalError", "message": xhr.statusText})
+                errback({"fullname": "BridgeInternalError", "message": xhr.statusText})
 
     def call(self, method_name, *args, callback, errback, **kwargs):
         xhr = window.XMLHttpRequest.new()
--- a/libervia/pages/_browser/browser_meta.json	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/pages/_browser/browser_meta.json	Fri Nov 12 17:48:30 2021 +0100
@@ -2,10 +2,10 @@
     "js": {
         "package": {
             "dependencies": {
-                "nunjucks": "latest",
-                "swiper": "^6.1.1",
-                "moment": "latest",
-                "ogv": "latest"
+                "nunjucks": "^3.2.3",
+                "swiper": "^6.8.4",
+                "moment": "^2.29.1",
+                "ogv": "^1.8.4"
             }
         },
         "brython_map": {
--- a/libervia/pages/blog/view/page_meta.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/pages/blog/view/page_meta.py	Fri Nov 12 17:48:30 2021 +0100
@@ -3,6 +3,7 @@
 import html
 from libervia.server.constants import Const as C
 from twisted.words.protocols.jabber import jid
+from twisted.web import server
 from sat.core.i18n import _, D_
 from sat.tools.common.template import safe
 from sat.tools.common import uri
@@ -144,7 +145,15 @@
             comment_data['items'] = comments['items']
             await appendComments(self, request, comments, profile)
 
-async def getBlogItems(self, request, service, node, item_id, extra, profile):
+async def getBlogItems(
+    self,
+    request: server.Request,
+    service: jid.JID,
+    node: str,
+    item_id,
+    extra: dict,
+    profile: str
+) -> dict:
     try:
         if item_id:
             items_id = [item_id]
@@ -211,7 +220,25 @@
     target_profile = template_data.get('target_profile')
 
     if blog_items:
-        if not item_id:
+        if item_id:
+            template_data["previous_page_url"] = self.getURL(
+                service.full(),
+                node,
+                before=item_id,
+                page_max=1
+            )
+            template_data["next_page_url"] = self.getURL(
+                service.full(),
+                node,
+                after=item_id,
+                page_max=1
+            )
+            blog_items["rsm"] = {
+                "last": item_id,
+                "first": item_id,
+            }
+            blog_items["complete"] = False
+        else:
             self.setPagination(request, blog_items)
     else:
         if item_id:
@@ -287,7 +314,9 @@
         uri_args['node'] = node
     if item_id:
         uri_args['item'] = item_id
-    template_data['xmpp_uri'] = uri.buildXMPPUri('pubsub', subtype='microblog', **uri_args)
+    template_data['xmpp_uri'] = uri.buildXMPPUri(
+        'pubsub', subtype='microblog', **uri_args
+    )
 
 
 async def on_data_post(self, request):
@@ -300,7 +329,7 @@
 
         if not body:
             self.pageError(request, C.HTTP_BAD_REQUEST)
-        comment_data = {"content": body}
+        comment_data = {"content_rich": body}
         try:
             await self.host.bridgeCall('mbSend',
                                        service,
--- a/libervia/pages/embed/page_meta.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/pages/embed/page_meta.py	Fri Nov 12 17:48:30 2021 +0100
@@ -15,7 +15,7 @@
     data = self.getRData(request)
     app_name = data["app_name"]
     try:
-        app_data = self.vhost_root.sat_apps[app_name]
+        app_data = self.vhost_root.libervia_apps[app_name]
     except KeyError:
         self.pageError(request, C.HTTP_BAD_REQUEST)
     template_data = request.template_data
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/pages/events/_browser/__init__.py	Fri Nov 12 17:48:30 2021 +0100
@@ -0,0 +1,52 @@
+from browser import DOMNode, document, aio
+from javascript import JSON
+from aio_bridge import Bridge, BridgeException
+import dialog
+
+bridge = Bridge()
+
+
+async def on_delete(evt):
+    evt.stopPropagation()
+    evt.preventDefault()
+    target = evt.currentTarget
+    item_elt = DOMNode(target.closest('.item'))
+    item_elt.classList.add("selected_for_deletion")
+    item = JSON.parse(item_elt.dataset.item)
+    confirmed = await dialog.Confirm(
+        f"Event {item['name']!r} will be deleted, are you sure?",
+        ok_label="delete",
+    ).ashow()
+
+    if not confirmed:
+        item_elt.classList.remove("selected_for_deletion")
+        return
+
+    try:
+        await bridge.interestRetract("", item['interest_id'])
+    except BridgeException as e:
+        dialog.notification.show(
+            f"Can't remove list {item['name']!r} from personal interests: {e}",
+            "error"
+        )
+    else:
+        print(f"{item['name']!r} removed successfuly from list of interests")
+        item_elt.classList.add("state_deleted")
+        item_elt.bind("transitionend", lambda evt: item_elt.remove())
+        if item.get("creator", False):
+            try:
+                await bridge.psNodeDelete(
+                    item['service'],
+                    item['node'],
+                )
+            except BridgeException as e:
+                dialog.notification.show(
+                    f"Error while deleting {item['name']!r}: {e}",
+                    "error"
+                )
+            else:
+                dialog.notification.show(f"{item['name']!r} has been deleted")
+
+
+for elt in document.select('.action_delete'):
+    elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- a/libervia/pages/lists/edit/page_meta.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/pages/lists/edit/page_meta.py	Fri Nov 12 17:48:30 2021 +0100
@@ -78,8 +78,7 @@
     template_data["new_list_item_xmlui"] = list_item
 
 
-@defer.inlineCallbacks
-def on_data_post(self, request):
+async def on_data_post(self, request):
     data = self.getRData(request)
     service = data["service"]
     node = data["node"]
@@ -94,20 +93,21 @@
     profile = self.getProfile(request)
 
     # we convert back body to XHTML
-    body = yield self.host.bridgeCall(
+    body = await self.host.bridgeCall(
         "syntaxConvert", posted_data['body'][0], "markdown", C.SYNTAX_XHTML,
         False, profile)
     posted_data['body'] = ['<div xmlns="{ns}">{body}</div>'.format(ns=C.NS_XHTML,
                                                                      body=body)]
 
     extra = {'update': True}
-    yield self.host.bridgeCall(
+    await self.host.bridgeCall(
         "listSet", service.full(), node, posted_data, "", item_id,
         data_format.serialise(extra), profile
     )
-    # we don't want to redirect to edit page on success, but to list overview
     data["post_redirect_page"] = (
-        self.getPageByName("lists"),
+        self.getPageByName("list_view"),
         service.full(),
         node or "@",
+        item_id
     )
+    return C.POST_NO_CONFIRM
--- a/libervia/pages/lists/view/page_meta.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/pages/lists/view/page_meta.py	Fri Nov 12 17:48:30 2021 +0100
@@ -32,6 +32,10 @@
     data = self.getRData(request)
     template_data = request.template_data
     service, node = data["service"], data["node"]
+    submitted_node = await self.host.bridgeCall(
+        "psSchemaSubmittedNodeGet",
+        node or self.host.ns_map["tickets"]
+    )
     profile = self.getProfile(request) or C.SERVICE_PROFILE
 
     self.checkCache(request, C.CACHE_PUBSUB, service=service, node=node, short="tickets")
@@ -47,7 +51,7 @@
         schema_raw = await self.host.bridgeCall(
             "psSchemaDictGet",
             service.full(),
-            node or self.host.ns_map["tickets"],
+            submitted_node,
             profile
         )
         schema = data_format.deserialise(schema_raw)
@@ -86,11 +90,13 @@
             affiliations = await self.host.bridgeCall(
                 "psNodeAffiliationsGet",
                 service.full() if service else "",
-                node,
+                submitted_node,
                 profile
             )
         except BridgeException as e:
-            log.warning(f"Can't get affiliations for node {node!r} at {service}: {e}")
+            log.warning(
+                f"Can't get affiliations for node {submitted_node!r} at {service}: {e}"
+            )
             template_data["owner"] = False
         else:
             is_owner = affiliations.get(self.getJid(request).userhost()) == 'owner'
--- a/libervia/pages/lists/view_item/page_meta.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/pages/lists/view_item/page_meta.py	Fri Nov 12 17:48:30 2021 +0100
@@ -1,7 +1,7 @@
 #!/usr/bin/env python3
 
 from twisted.words.protocols.jabber import jid
-from sat.core.i18n import _
+from sat.core.i18n import _, D_
 from sat.tools.common import template_xmlui
 from sat.tools.common import uri
 from sat.tools.common import data_format
@@ -26,6 +26,20 @@
         log.warning(_("no list item id specified"))
         self.pageError(request, C.HTTP_BAD_REQUEST)
 
+
+def add_breadcrumb(self, request, breadcrumbs):
+    data = self.getRData(request)
+    list_url = self.getPageByName("lists").getURL(data["service"].full(),
+                                                  data.get("node") or "@")
+    breadcrumbs.append({
+        "label": D_("List"),
+        "url": list_url
+    })
+    breadcrumbs.append({
+        "label": D_("Item"),
+    })
+
+
 async def prepare_render(self, request):
     data = self.getRData(request)
     template_data = request.template_data
@@ -35,8 +49,10 @@
         data.get("node", ""),
         data["item_id"],
     )
+    namespace = node or self.host.ns_map["tickets"]
+    node = await self.host.bridgeCall("psSchemaSubmittedNodeGet", namespace)
+
     profile = self.getProfile(request)
-
     if profile is None:
         profile = C.SERVICE_PROFILE
 
@@ -50,7 +66,7 @@
         {"labels_as_list": C.BOOL_TRUE},
         profile,
     )
-    list_items, metadata = data_format.deserialise(list_raw, type_check=list)
+    list_items, __ = data_format.deserialise(list_raw, type_check=list)
     list_item = [template_xmlui.create(self.host, x) for x in list_items][0]
     template_data["item"] = list_item
     try:
@@ -91,7 +107,6 @@
         is_publisher = publisher.userhostJID() == session.jid.userhostJID()
         affiliation = None
         if not is_publisher:
-            node = node or self.host.ns_map["tickets"]
             affiliation = await self.host.getAffiliation(request, service, node)
         if is_publisher or affiliation == "owner":
             self.exposeToScripts(
@@ -110,7 +125,7 @@
 
     # we add xmpp: URI
     uri_args = {'path': service.full()}
-    uri_args['node'] = node or self.host.ns_map["tickets"]
+    uri_args['node'] = node
     if item_id:
         uri_args['item'] = item_id
     template_data['xmpp_uri'] = uri.buildXMPPUri('pubsub', **uri_args)
--- a/libervia/server/constants.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/server/constants.py	Fri Nov 12 17:48:30 2021 +0100
@@ -26,7 +26,8 @@
     APP_NAME_ALT = APP_NAME
     APP_NAME_FILE = "libervia_web"
     CONFIG_SECTION = APP_COMPONENT.lower()
-    SERVICE_PROFILE = "libervia"  # the SàT profile that is used for exporting the service
+    # the Libervia profile that is used for public operations (when nobody is connected)
+    SERVICE_PROFILE = "libervia"
 
     SESSION_TIMEOUT = 7200  # Session's timeout, after that the user will be disconnected
     HTML_DIR = "html/"
--- a/libervia/server/pages.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/server/pages.py	Fri Nov 12 17:48:30 2021 +0100
@@ -546,9 +546,14 @@
                     log.info(_("{page} reloaded").format(page=resource))
 
     def checkCSRF(self, request):
-        csrf_token = self.host.getSessionData(
+        session = self.host.getSessionData(
             request, session_iface.ISATSession
-        ).csrf_token
+        )
+        if session.profile is None:
+            # CSRF doesn't make sense when no user is logged
+            log.debug("disabling CSRF check because service profile is used")
+            return
+        csrf_token = session.csrf_token
         given_csrf = request.getHeader("X-Csrf-Token")
         if given_csrf is None:
             try:
@@ -577,6 +582,10 @@
         for name, value in kwargs.items():
             if value is None:
                 value = "null"
+            elif isinstance(value, str):
+                # FIXME: workaround for subtype used by python-dbus (dbus.String)
+                #   to be removed when we get rid of python-dbus
+                value = repr(str(value))
             else:
                 value = repr(value)
             scripts.add(Script(content=f"var {name}={value};"))
@@ -665,11 +674,12 @@
             else url.encode("utf-8"),
         )
 
-    def getURL(self, *args):
+    def getURL(self, *args: str, **kwargs: str) -> str:
         """retrieve URL of the page set arguments
 
-        *args(list[unicode]): argument to add to the URL as path elements
-            empty or None arguments will be ignored
+        @param *args: arguments to add to the URL as path elements empty or None
+            arguments will be ignored
+        @param **kwargs: query parameters
         """
         url_args = [quote(a) for a in args if a]
 
@@ -677,15 +687,29 @@
             #  we check for redirection
             redirect_data = self.pages_redirects[self.name]
             args_hash = tuple(args)
-            for limit in range(len(args) + 1):
+            for limit in range(len(args), -1, -1):
                 current_hash = args_hash[:limit]
                 if current_hash in redirect_data:
                     url_base = redirect_data[current_hash]
                     remaining = args[limit:]
                     remaining_url = "/".join(remaining)
-                    return os.path.join("/", url_base, remaining_url)
+                    url = urllib.parse.urljoin(url_base, remaining_url)
+                    break
+            else:
+                url = os.path.join(self.url, *url_args)
+        else:
+            url = os.path.join(self.url, *url_args)
 
-        return os.path.join(self.url, *url_args)
+        if kwargs:
+            encoded = urllib.parse.urlencode(
+                {k: v for k, v in kwargs.items()}
+            )
+            url +=  f"?{encoded}"
+
+        return self.host.checkRedirection(
+            self.vhost_root,
+            url
+        )
 
     def getCurrentURL(self, request):
         """retrieve URL used to access this page
@@ -961,7 +985,7 @@
         else:
             assert not {"rsm_max", "rsm_after", "rsm_before",
                         C.KEY_ORDER_BY}.intersection(list(extra.keys()))
-        extra["rsm_max"] = str(page_max)
+        extra["rsm_max"] = params.get("page_max", str(page_max))
         if order_by is not None:
             extra[C.KEY_ORDER_BY] = order_by
         if 'after' in params:
@@ -974,13 +998,13 @@
             extra['rsm_before'] = ""
         return extra
 
-    def setPagination(self, request, pubsub_data):
+    def setPagination(self, request: server.Request, pubsub_data: dict) -> None:
         """Add to template_data if suitable
 
         "previous_page_url" and "next_page_url" will be added using respectively
         "before" and "after" URL parameters
-        @param request(server.Request): current HTTP request
-        @param pubsub_data(dict): pubsub metadata
+        @param request: current HTTP request
+        @param pubsub_data: pubsub metadata
         """
         template_data = request.template_data
         extra = {}
@@ -996,6 +1020,11 @@
         if search is not None:
             extra['search'] = search.strip()
 
+        # same for page_max
+        page_max = self.getPostedData(request, 'page_max', raise_on_missing=False)
+        if page_max is not None:
+            extra['page_max'] = page_max
+
         if rsm.get("index", 1) > 0:
             # We only show previous button if it's not the first page already.
             # If we have no index, we default to display the button anyway
@@ -1709,7 +1738,7 @@
         accept_language = request.getHeader("accept-language")
         if not accept_language:
             return
-        accepted = {a.strip() for a in accept_language.split(',')}
+        accepted = [a.strip() for a in accept_language.split(',')]
         available = [str(l) for l in self.host.renderer.translations]
         for lang in accepted:
             lang = lang.split(';')[0].strip().lower()
@@ -1785,10 +1814,13 @@
             # if template_data doesn't exist, it's the beginning of the request workflow
             # so we fill essential data
             session_data = self.host.getSessionData(request, session_iface.ISATSession)
+            profile = session_data.profile
             request.template_data = {
-                "profile": session_data.profile,
-                "csrf_token": session_data.csrf_token,
-                "session_uuid": session_data.uuid,
+                "profile": profile,
+                # it's important to not add CSRF token and session uuid if service profile
+                # is used because the page may be cached, and the token then leaked
+                "csrf_token": "" if profile is None else session_data.csrf_token,
+                "session_uuid": "public" if profile is None else session_data.uuid,
                 "breadcrumbs": []
             }
 
--- a/libervia/server/restricted_bridge.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/server/restricted_bridge.py	Fri Nov 12 17:48:30 2021 +0100
@@ -16,8 +16,9 @@
 # You should have received a copy of the GNU Affero General Public License
 # along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
+from sat.tools.common import data_format
+from sat.core import exceptions
 from libervia.server.constants import Const as C
-from sat.tools.common import data_format
 
 
 class RestrictedBridge:
@@ -31,6 +32,13 @@
         self.host = host
         self.security_limit = C.SECURITY_LIMIT
 
+    def noServiceProfile(self, profile):
+        """Raise an error if service profile is used"""
+        if profile == C.SERVICE_PROFILE:
+            raise exceptions.PermissionError(
+                "This action is not allowed for service profile"
+            )
+
     async def getContacts(self, profile):
         return await self.host.bridgeCall("getContacts", profile)
 
@@ -47,14 +55,17 @@
             "identitiesBaseGet", profile)
 
     async def psNodeDelete(self, service_s, node, profile):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "psNodeDelete", service_s, node, profile)
 
     async def psNodeAffiliationsSet(self, service_s, node, affiliations, profile):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "psNodeAffiliationsSet", service_s, node, affiliations, profile)
 
     async def psItemRetract(self, service_s, node, item_id, notify, profile):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "psItemRetract", service_s, node, item_id, notify, profile)
 
@@ -63,24 +74,28 @@
             "mbPreview", service_s, node, data, profile)
 
     async def listSet(self, service_s, node, values, schema, item_id, extra, profile):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "listSet", service_s, node, values, "", item_id, "", profile)
 
 
     async def fileHTTPUploadGetSlot(
         self, filename, size, content_type, upload_jid, profile):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "fileHTTPUploadGetSlot", filename, size, content_type,
             upload_jid, profile)
 
     async def fileSharingDelete(
         self, service_jid, path, namespace, profile):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "fileSharingDelete", service_jid, path, namespace, profile)
 
     async def interestsRegisterFileSharing(
         self, service, repos_type, namespace, path, name, extra_s, profile
     ):
+        self.noServiceProfile(profile)
         if extra_s:
             # we only allow "thumb_url" here
             extra = data_format.deserialise(extra_s)
@@ -97,12 +112,14 @@
     async def interestRetract(
         self, service_jid, item_id, profile
     ):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "interestRetract", service_jid, item_id, profile)
 
     async def psInvite(
         self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile
     ):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "psInvite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile
         )
@@ -111,6 +128,7 @@
         self, invitee_jid_s, service_s, repos_type, namespace, path, name, extra_s,
         profile
     ):
+        self.noServiceProfile(profile)
         if extra_s:
             # we only allow "thumb_url" here
             extra = data_format.deserialise(extra_s)
@@ -127,6 +145,7 @@
     async def FISAffiliationsSet(
         self, service_s, namespace, path, affiliations, profile
     ):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "FISAffiliationsSet", service_s, namespace, path, affiliations, profile
         )
@@ -134,6 +153,7 @@
     async def invitationSimpleCreate(
         self, invitee_email, invitee_name, url_template, extra_s, profile
     ):
+        self.noServiceProfile(profile)
         return await self.host.bridgeCall(
             "invitationSimpleCreate", invitee_email, invitee_name, url_template, extra_s,
             profile
--- a/libervia/server/server.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/libervia/server/server.py	Fri Nov 12 17:48:30 2021 +0100
@@ -23,6 +23,7 @@
 import urllib.request, urllib.error
 import time
 import copy
+from typing import Optional
 from pathlib import Path
 from twisted.application import service
 from twisted.internet import reactor, defer, inotify
@@ -179,6 +180,7 @@
     def getChildForRequest(self, request):
         return super().getChildForRequest(request)
 
+
 class LiberviaRootResource(ProtectedFile):
     """Specialized resource for Libervia root
 
@@ -234,8 +236,8 @@
         self.pages_redirects = {}
         self.cached_urls = {}
         self.main_menu = None
-        # map SàT application names => data
-        self.sat_apps = {}
+        # map Libervia application names => data
+        self.libervia_apps = {}
         self.build_path = host.getBuildPath(site_name)
         self.build_path.mkdir(parents=True, exist_ok=True)
         self.dev_build_path = host.getBuildPath(site_name, dev=True)
@@ -295,7 +297,7 @@
         await self.host.bridgeCall(
             "applicationStart", app_name, data_format.serialise(extra)
         )
-        app_data = self.sat_apps[app_name] = data_format.deserialise(
+        app_data = self.libervia_apps[app_name] = data_format.deserialise(
             await self.host.bridgeCall(
                 "applicationExposedGet", app_name, "", ""))
 
@@ -337,168 +339,184 @@
         self.redirections = {}
         self.inv_redirections = {}  # new URL to old URL map
 
-        for old, new_data in url_redirections.items():
-            # new_data can be a dictionary or a unicode url
-            if isinstance(new_data, dict):
-                # new_data dict must contain either "url", "page" or "path" key
-                # (exclusive)
-                # if "path" is used, a file url is constructed with it
-                if len({"path", "url", "page"}.intersection(list(new_data.keys()))) != 1:
-                    raise ValueError(
-                        'You must have one and only one of "url", "page" or "path" key '
-                        'in your url_redirections_dict data')
-                if "url" in new_data:
-                    new = new_data["url"]
-                elif "page" in new_data:
-                    new = new_data
-                    new["type"] = "page"
-                    new.setdefault("path_args", [])
-                    if not isinstance(new["path_args"], list):
-                        log.error(
-                            _('"path_args" in redirection of {old} must be a list. '
-                              'Ignoring the redirection'.format(old=old)))
-                        continue
-                    new.setdefault("query_args", {})
-                    if not isinstance(new["query_args"], dict):
-                        log.error(
-                            _(
-                                '"query_args" in redirection of {old} must be a '
-                                'dictionary. Ignoring the redirection'.format(old=old)))
-                        continue
-                    new["path_args"] = [quote(a) for a in new["path_args"]]
-                    # we keep an inversed dict of page redirection
-                    # (page/path_args => redirecting URL)
-                    # so getURL can return the redirecting URL if the same arguments
-                    # are used # making the URL consistent
-                    args_hash = tuple(new["path_args"])
-                    self.pages_redirects.setdefault(new_data["page"], {})[
-                        args_hash
-                    ] = old
+        for old, new_data_list in url_redirections.items():
+            # several redirections can be used for one path by using a list.
+            # The redirection will be done using first item of the list, and all items
+            # will be used for inverse redirection.
+            # e.g. if a => [b, c], a will redirect to c, and b and c will both be
+            # equivalent to a
+            if not isinstance(new_data_list, list):
+                new_data_list = [new_data_list]
+            for new_data in new_data_list:
+                # new_data can be a dictionary or a unicode url
+                if isinstance(new_data, dict):
+                    # new_data dict must contain either "url", "page" or "path" key
+                    # (exclusive)
+                    # if "path" is used, a file url is constructed with it
+                    if ((
+                        len(
+                            {"path", "url", "page"}.intersection(list(new_data.keys()))
+                        ) != 1
+                    )):
+                        raise ValueError(
+                            'You must have one and only one of "url", "page" or "path" '
+                            'key in your url_redirections_dict data'
+                        )
+                    if "url" in new_data:
+                        new = new_data["url"]
+                    elif "page" in new_data:
+                        new = new_data
+                        new["type"] = "page"
+                        new.setdefault("path_args", [])
+                        if not isinstance(new["path_args"], list):
+                            log.error(
+                                _('"path_args" in redirection of {old} must be a list. '
+                                  'Ignoring the redirection'.format(old=old)))
+                            continue
+                        new.setdefault("query_args", {})
+                        if not isinstance(new["query_args"], dict):
+                            log.error(
+                                _(
+                                    '"query_args" in redirection of {old} must be a '
+                                    'dictionary. Ignoring the redirection'
+                                ).format(old=old)
+                            )
+                            continue
+                        new["path_args"] = [quote(a) for a in new["path_args"]]
+                        # we keep an inversed dict of page redirection
+                        # (page/path_args => redirecting URL)
+                        # so getURL can return the redirecting URL if the same arguments
+                        # are used # making the URL consistent
+                        args_hash = tuple(new["path_args"])
+                        self.pages_redirects.setdefault(new_data["page"], {}).setdefault(
+                            args_hash,
+                            old
+                        )
 
-                    # we need lists in query_args because it will be used
-                    # as it in request.path_args
-                    for k, v in new["query_args"].items():
-                        if isinstance(v, str):
-                            new["query_args"][k] = [v]
-                elif "path" in new_data:
-                    new = "file:{}".format(urllib.parse.quote(new_data["path"]))
-            elif isinstance(new_data, str):
-                new = new_data
-                new_data = {}
-            else:
-                log.error(
-                    _("ignoring invalid redirection value: {new_data}").format(
-                        new_data=new_data
+                        # we need lists in query_args because it will be used
+                        # as it in request.path_args
+                        for k, v in new["query_args"].items():
+                            if isinstance(v, str):
+                                new["query_args"][k] = [v]
+                    elif "path" in new_data:
+                        new = "file:{}".format(urllib.parse.quote(new_data["path"]))
+                elif isinstance(new_data, str):
+                    new = new_data
+                    new_data = {}
+                else:
+                    log.error(
+                        _("ignoring invalid redirection value: {new_data}").format(
+                            new_data=new_data
+                        )
                     )
-                )
-                continue
+                    continue
 
-            # some normalization
-            if not old.strip():
-                # root URL special case
-                old = ""
-            elif not old.startswith("/"):
-                log.error(_("redirected url must start with '/', got {value}. Ignoring")
-                          .format(value=old))
-                continue
-            else:
-                old = self._normalizeURL(old)
+                # some normalization
+                if not old.strip():
+                    # root URL special case
+                    old = ""
+                elif not old.startswith("/"):
+                    log.error(
+                        _("redirected url must start with '/', got {value}. Ignoring")
+                        .format(value=old)
+                    )
+                    continue
+                else:
+                    old = self._normalizeURL(old)
 
-            if isinstance(new, dict):
-                # dict are handled differently, they contain data
-                # which ared use dynamically when the request is done
-                self.redirections[old] = new
-                if not old:
-                    if new["type"] == "page":
-                        log.info(
-                            _("Root URL redirected to page {name}").format(
-                                name=new["page"]
+                if isinstance(new, dict):
+                    # dict are handled differently, they contain data
+                    # which ared use dynamically when the request is done
+                    self.redirections.setdefault(old, new)
+                    if not old:
+                        if new["type"] == "page":
+                            log.info(
+                                _("Root URL redirected to page {name}").format(
+                                    name=new["page"]
+                                )
                             )
-                        )
-                else:
-                    if new["type"] == "page":
-                        page = self.getPageByName(new["page"])
-                        url = page.getURL(*new.get("path_args", []))
-                        self.inv_redirections[url] = old
-                continue
+                    else:
+                        if new["type"] == "page":
+                            page = self.getPageByName(new["page"])
+                            url = page.getURL(*new.get("path_args", []))
+                            self.inv_redirections[url] = old
+                    continue
 
-            # at this point we have a redirection URL in new, we can parse it
-            new_url = urllib.parse.urlsplit(new)
+                # at this point we have a redirection URL in new, we can parse it
+                new_url = urllib.parse.urlsplit(new)
 
-            # we handle the known URL schemes
-            if new_url.scheme == "xmpp":
-                location = self.getPagePathFromURI(new)
-                if location is None:
-                    log.warning(
-                        _("ignoring redirection, no page found to handle this URI: "
-                          "{uri}").format(uri=new))
-                    continue
-                request_data = self._getRequestData(location)
-                if old:
+                # we handle the known URL schemes
+                if new_url.scheme == "xmpp":
+                    location = self.getPagePathFromURI(new)
+                    if location is None:
+                        log.warning(
+                            _("ignoring redirection, no page found to handle this URI: "
+                              "{uri}").format(uri=new))
+                        continue
+                    request_data = self._getRequestData(location)
                     self.inv_redirections[location] = old
 
-            elif new_url.scheme in ("", "http", "https"):
-                # direct redirection
-                if new_url.netloc:
-                    raise NotImplementedError(
-                        "netloc ({netloc}) is not implemented yet for "
-                        "url_redirections_dict, it is not possible to redirect to an "
-                        "external website".format(netloc=new_url.netloc))
-                location = urllib.parse.urlunsplit(
-                    ("", "", new_url.path, new_url.query, new_url.fragment)
-                )
-                request_data = self._getRequestData(location)
-                if old:
+                elif new_url.scheme in ("", "http", "https"):
+                    # direct redirection
+                    if new_url.netloc:
+                        raise NotImplementedError(
+                            "netloc ({netloc}) is not implemented yet for "
+                            "url_redirections_dict, it is not possible to redirect to an "
+                            "external website".format(netloc=new_url.netloc))
+                    location = urllib.parse.urlunsplit(
+                        ("", "", new_url.path, new_url.query, new_url.fragment)
+                    )
+                    request_data = self._getRequestData(location)
                     self.inv_redirections[location] = old
 
-            elif new_url.scheme == "file":
-                # file or directory
-                if new_url.netloc:
-                    raise NotImplementedError(
-                        "netloc ({netloc}) is not implemented for url redirection to "
-                        "file system, it is not possible to redirect to an external "
-                        "host".format(
-                            netloc=new_url.netloc))
-                path = urllib.parse.unquote(new_url.path)
-                if not os.path.isabs(path):
-                    raise ValueError(
-                        "file redirection must have an absolute path: e.g. "
-                        "file:/path/to/my/file")
-                # for file redirection, we directly put child here
-                resource_class = (
-                    ProtectedFile if new_data.get("protected", True) else static.File
-                )
-                res = resource_class(path, defaultType="application/octet-stream")
-                self.addResourceToPath(old, res)
-                log.info("[{host_name}] Added redirection from /{old} to file system "
-                         "path {path}".format(host_name=self.host_name,
-                                               old=old,
-                                               path=path))
+                elif new_url.scheme == "file":
+                    # file or directory
+                    if new_url.netloc:
+                        raise NotImplementedError(
+                            "netloc ({netloc}) is not implemented for url redirection to "
+                            "file system, it is not possible to redirect to an external "
+                            "host".format(
+                                netloc=new_url.netloc))
+                    path = urllib.parse.unquote(new_url.path)
+                    if not os.path.isabs(path):
+                        raise ValueError(
+                            "file redirection must have an absolute path: e.g. "
+                            "file:/path/to/my/file")
+                    # for file redirection, we directly put child here
+                    resource_class = (
+                        ProtectedFile if new_data.get("protected", True) else static.File
+                    )
+                    res = resource_class(path, defaultType="application/octet-stream")
+                    self.addResourceToPath(old, res)
+                    log.info("[{host_name}] Added redirection from /{old} to file system "
+                             "path {path}".format(host_name=self.host_name,
+                                                   old=old,
+                                                   path=path))
 
-                # we don't want to use redirection system, so we continue here
-                continue
-
-            elif new_url.scheme == "sat-app":
-                # a SàT application
-
-                app_name = urllib.parse.unquote(new_url.path).lower().strip()
-                extra = {"url_prefix": f"/{old}"}
-                try:
-                    await self._startApp(app_name, extra)
-                except Exception as e:
-                    log.warning(_(
-                        "Can't launch {app_name!r} for path /{old}: {e}").format(
-                        app_name=app_name, old=old, e=e))
+                    # we don't want to use redirection system, so we continue here
                     continue
 
-                log.info("[{host_name}] Added redirection from /{old} to application "
-                         "{app_name}".format(
-                             host_name=self.host_name,
-                             old=old,
-                             app_name=app_name))
+                elif new_url.scheme == "libervia-app":
+                    # a Libervia application
 
-                # normal redirection system is not used here
-                continue
+                    app_name = urllib.parse.unquote(new_url.path).lower().strip()
+                    extra = {"url_prefix": f"/{old}"}
+                    try:
+                        await self._startApp(app_name, extra)
+                    except Exception as e:
+                        log.warning(_(
+                            "Can't launch {app_name!r} for path /{old}: {e}").format(
+                            app_name=app_name, old=old, e=e))
+                        continue
+
+                    log.info("[{host_name}] Added redirection from /{old} to application "
+                             "{app_name}".format(
+                                 host_name=self.host_name,
+                                 old=old,
+                                 app_name=app_name))
+
+                    # normal redirection system is not used here
+                    continue
             elif new_url.scheme == "proxy":
                 # a reverse proxy
                 host, port = new_url.hostname, new_url.port
@@ -521,18 +539,18 @@
 
                 # normal redirection system is not used here
                 continue
-            else:
-                raise NotImplementedError(
-                    "{scheme}: scheme is not managed for url_redirections_dict".format(
-                        scheme=new_url.scheme
+                else:
+                    raise NotImplementedError(
+                        "{scheme}: scheme is not managed for url_redirections_dict".format(
+                            scheme=new_url.scheme
+                        )
                     )
-                )
 
-            self.redirections[old] = request_data
-            if not old:
-                log.info(_("[{host_name}] Root URL redirected to {uri}")
-                    .format(host_name=self.host_name,
-                            uri=request_data[1]))
+                self.redirections.setdefault(old, request_data)
+                if not old:
+                    log.info(_("[{host_name}] Root URL redirected to {uri}")
+                        .format(host_name=self.host_name,
+                                uri=request_data[1]))
 
         # the default root URL, if not redirected
         if not "" in self.redirections:
@@ -554,8 +572,8 @@
                     log.error(msg)
                     raise ValueError(msg)
                 page_name, url = menu
-            elif menu.startswith("sat-app:"):
-                app_name = menu[8:].strip().lower()
+            elif menu.startswith("libervia-app:"):
+                app_name = menu[13:].strip().lower()
                 app_data = await self._startApp(app_name)
                 front_url = app_data['front_url']
                 options = self.host.options
@@ -746,7 +764,7 @@
             # if nothing was found, we try our luck with redirections
             # XXX: we want redirections to happen only if everything else failed
             path_elt = request.prepath + request.postpath
-            for idx in range(len(path_elt), 0, -1):
+            for idx in range(len(path_elt), -1, -1):
                 test_url = b"/".join(path_elt[:idx]).decode('utf-8').lower()
                 if test_url in self.redirections:
                     request_data = self.redirections[test_url]
@@ -1660,57 +1678,59 @@
         """
         ext_data = self.base_url_ext_data
         url_path = request.URLPath()
-        if not ext_data.scheme or not ext_data.netloc:
-            #  ext_data is not specified, we check headers
-            if request.requestHeaders.hasHeader("x-forwarded-host"):
-                # we are behing a proxy
-                # we fill proxy_scheme and proxy_netloc value
-                proxy_host = request.requestHeaders.getRawHeaders("x-forwarded-host")[0]
-                try:
-                    proxy_server = request.requestHeaders.getRawHeaders(
-                        "x-forwarded-server"
-                    )[0]
-                except TypeError:
-                    # no x-forwarded-server found, we use proxy_host
-                    proxy_netloc = proxy_host
-                else:
-                    # if the proxy host has a port, we use it with server name
-                    proxy_port = urllib.parse.urlsplit("//{}".format(proxy_host)).port
-                    proxy_netloc = (
-                        "{}:{}".format(proxy_server, proxy_port)
-                        if proxy_port is not None
-                        else proxy_server
-                    )
-                try:
-                    proxy_scheme = request.requestHeaders.getRawHeaders(
-                        "x-forwarded-proto"
-                    )[0]
-                except TypeError:
-                    proxy_scheme = None
-            else:
-                proxy_scheme, proxy_netloc = None, None
+
+        try:
+            forwarded = request.requestHeaders.getRawHeaders(
+                "forwarded"
+            )[0]
+        except TypeError:
+            # we try deprecated headers
+            try:
+                proxy_netloc = request.requestHeaders.getRawHeaders(
+                    "x-forwarded-host"
+                )[0]
+            except TypeError:
+                proxy_netloc = None
+            try:
+                proxy_scheme = request.requestHeaders.getRawHeaders(
+                    "x-forwarded-proto"
+                )[0]
+            except TypeError:
+                proxy_scheme = None
         else:
-            proxy_scheme, proxy_netloc = None, None
+            fwd_data = {
+                k.strip(): v.strip()
+                for k,v in (d.split("=") for d in forwarded.split(";"))
+            }
+            proxy_netloc = fwd_data.get("host")
+            proxy_scheme = fwd_data.get("proto")
 
         return urllib.parse.SplitResult(
-            ext_data.scheme or proxy_scheme or url_path.scheme.decode("utf-8"),
-            ext_data.netloc or proxy_netloc or url_path.netloc.decode("utf-8"),
+            ext_data.scheme or proxy_scheme or url_path.scheme.decode(),
+            ext_data.netloc or proxy_netloc or url_path.netloc.decode(),
             ext_data.path or "/",
             "",
             "",
         )
 
-    def getExtBaseURL(self, request, path="", query="", fragment="", scheme=None):
+    def getExtBaseURL(
+            self,
+            request: server.Request,
+            path: str = "",
+            query: str = "",
+            fragment: str = "",
+            scheme: Optional[str] = None,
+            ) -> str:
         """Get external URL according to given elements
 
         external URL is the URL seen by external user
-        @param path(unicode): same as for urlsplit.urlsplit
+        @param path: same as for urlsplit.urlsplit
             path will be prefixed to follow found external URL if suitable
-        @param params(unicode): same as for urlsplit.urlsplit
-        @param query(unicode): same as for urlsplit.urlsplit
-        @param fragment(unicode): same as for urlsplit.urlsplit
-        @param scheme(unicode, None): if not None, will override scheme from base URL
-        @return (unicode): external URL
+        @param params: same as for urlsplit.urlsplit
+        @param query: same as for urlsplit.urlsplit
+        @param fragment: same as for urlsplit.urlsplit
+        @param scheme: if not None, will override scheme from base URL
+        @return: external URL
         """
         split_result = self.getExtBaseURLData(request)
         return urllib.parse.urlunsplit(
@@ -1723,23 +1743,23 @@
             )
         )
 
-    def checkRedirection(self, vhost_root, url):
+    def checkRedirection(self, vhost_root: LiberviaRootResource, url_path: str) -> str:
         """check is a part of the URL prefix is redirected then replace it
 
-        @param vhost_root(web_resource.Resource): root of this virtual host
-        @param url(unicode): url to check
-        @return (unicode): possibly redirected URL which should link to the same location
+        @param vhost_root: root of this virtual host
+        @param url_path: path of the url to check
+        @return: possibly redirected URL which should link to the same location
         """
         inv_redirections = vhost_root.inv_redirections
-        url_parts = url.strip("/").split("/")
-        for idx in range(len(url), 0, -1):
+        url_parts = url_path.strip("/").split("/")
+        for idx in range(len(url_parts), -1, -1):
             test_url = "/" + "/".join(url_parts[:idx])
             if test_url in inv_redirections:
                 rem_url = url_parts[idx:]
                 return os.path.join(
                     "/", "/".join([inv_redirections[test_url]] + rem_url)
                 )
-        return url
+        return url_path
 
     ## Sessions ##
 
--- a/twisted/plugins/libervia_server.py	Tue Sep 28 18:18:37 2021 +0200
+++ b/twisted/plugins/libervia_server.py	Fri Nov 12 17:48:30 2021 +0100
@@ -31,6 +31,7 @@
     except ImportError:
         pass
 
+import re
 import os.path
 import libervia
 import sat
@@ -48,7 +49,9 @@
 import configparser
 
 
-if libervia.__version__ != sat.__version__:
+RE_VER_POST = re.compile(r"\.post[0-9]+")
+
+if RE_VER_POST.sub("", libervia.__version__) != RE_VER_POST.sub("", sat.__version__):
     import sys
 
     sys.stderr.write(