changeset 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 b8ed9726525b
children 01b8d68edd70
files doc/conf.py libervia/VERSION libervia/__init__.py libervia/common/__init__.py libervia/common/constants.py libervia/pages/_bridge/page_meta.py libervia/pages/_browser/__init__.py libervia/pages/_browser/alt_media_player.py libervia/pages/_browser/bridge.py libervia/pages/_browser/browser_meta.json libervia/pages/_browser/cache.py libervia/pages/_browser/dialog.py libervia/pages/_browser/editor.py libervia/pages/_browser/errors.py libervia/pages/_browser/invitation.py libervia/pages/_browser/loading.py libervia/pages/_browser/slideshow.py libervia/pages/_browser/template.py libervia/pages/_browser/tmp_aio.py libervia/pages/app/page_meta.py libervia/pages/blog/edit/_browser/__init__.py libervia/pages/blog/edit/page_meta.py libervia/pages/blog/page_meta.py libervia/pages/blog/view/_browser/__init__.py libervia/pages/blog/view/atom.xml/page_meta.py libervia/pages/blog/view/page_meta.py libervia/pages/calendar/_browser/__init__.py libervia/pages/calendar/page_meta.py libervia/pages/calls/_browser/__init__.py libervia/pages/calls/page_meta.py libervia/pages/chat/page_meta.py libervia/pages/chat/select/page_meta.py libervia/pages/embed/page_meta.py libervia/pages/events/_browser/__init__.py libervia/pages/events/admin/page_meta.py libervia/pages/events/new/page_meta.py libervia/pages/events/page_meta.py libervia/pages/events/rsvp/page_meta.py libervia/pages/events/view/page_meta.py libervia/pages/files/list/page_meta.py libervia/pages/files/page_meta.py libervia/pages/files/view/page_meta.py libervia/pages/forums/list/page_meta.py libervia/pages/forums/page_meta.py libervia/pages/forums/topics/new/_browser/__init__.py libervia/pages/forums/topics/new/page_meta.py libervia/pages/forums/topics/page_meta.py libervia/pages/forums/view/page_meta.py libervia/pages/g/e/page_meta.py libervia/pages/g/page_meta.py libervia/pages/lists/_browser/__init__.py libervia/pages/lists/create/page_meta.py libervia/pages/lists/create_from_tpl/page_meta.py libervia/pages/lists/edit/page_meta.py libervia/pages/lists/new/page_meta.py libervia/pages/lists/page_meta.py libervia/pages/lists/view/_browser/__init__.py libervia/pages/lists/view/page_meta.py libervia/pages/lists/view_item/_browser/__init__.py libervia/pages/lists/view_item/page_meta.py libervia/pages/login/logged/page_meta.py libervia/pages/login/page_meta.py libervia/pages/merge-requests/disco/page_meta.py libervia/pages/merge-requests/edit/page_meta.py libervia/pages/merge-requests/new/page_meta.py libervia/pages/merge-requests/page_meta.py libervia/pages/merge-requests/view/page_meta.py libervia/pages/photos/_browser/__init__.py libervia/pages/photos/album/_browser/__init__.py libervia/pages/photos/album/page_meta.py libervia/pages/photos/new/page_meta.py libervia/pages/photos/page_meta.py libervia/pages/register/page_meta.py libervia/pages/u/atom.xml/page_meta.py libervia/pages/u/blog/page_meta.py libervia/pages/u/page_meta.py libervia/server/__init__.py libervia/server/classes.py libervia/server/constants.py libervia/server/html_tools.py libervia/server/launcher.py libervia/server/pages.py libervia/server/pages_tools.py libervia/server/proxy.py libervia/server/resources.py libervia/server/restricted_bridge.py libervia/server/server.py libervia/server/session_iface.py libervia/server/tasks/implicit/__init__.py libervia/server/tasks/implicit/task_brython.py libervia/server/tasks/implicit/task_js_modules.py libervia/server/tasks/implicit/task_sass.py libervia/server/tasks/manager.py libervia/server/tasks/task.py libervia/server/utils.py libervia/server/websockets.py libervia/web/VERSION libervia/web/__init__.py libervia/web/common/__init__.py libervia/web/common/constants.py libervia/web/pages/_bridge/page_meta.py libervia/web/pages/_browser/__init__.py libervia/web/pages/_browser/alt_media_player.py libervia/web/pages/_browser/bridge.py libervia/web/pages/_browser/browser_meta.json libervia/web/pages/_browser/cache.py libervia/web/pages/_browser/dialog.py libervia/web/pages/_browser/editor.py libervia/web/pages/_browser/errors.py libervia/web/pages/_browser/invitation.py libervia/web/pages/_browser/loading.py libervia/web/pages/_browser/slideshow.py libervia/web/pages/_browser/template.py libervia/web/pages/_browser/tmp_aio.py libervia/web/pages/app/page_meta.py libervia/web/pages/blog/edit/_browser/__init__.py libervia/web/pages/blog/edit/page_meta.py libervia/web/pages/blog/page_meta.py libervia/web/pages/blog/view/_browser/__init__.py libervia/web/pages/blog/view/atom.xml/page_meta.py libervia/web/pages/blog/view/page_meta.py libervia/web/pages/calendar/_browser/__init__.py libervia/web/pages/calendar/page_meta.py libervia/web/pages/calls/_browser/__init__.py libervia/web/pages/calls/page_meta.py libervia/web/pages/chat/page_meta.py libervia/web/pages/chat/select/page_meta.py libervia/web/pages/embed/page_meta.py libervia/web/pages/events/_browser/__init__.py libervia/web/pages/events/admin/page_meta.py libervia/web/pages/events/new/page_meta.py libervia/web/pages/events/page_meta.py libervia/web/pages/events/rsvp/page_meta.py libervia/web/pages/events/view/page_meta.py libervia/web/pages/files/list/page_meta.py libervia/web/pages/files/page_meta.py libervia/web/pages/files/view/page_meta.py libervia/web/pages/forums/list/page_meta.py libervia/web/pages/forums/page_meta.py libervia/web/pages/forums/topics/new/_browser/__init__.py libervia/web/pages/forums/topics/new/page_meta.py libervia/web/pages/forums/topics/page_meta.py libervia/web/pages/forums/view/page_meta.py libervia/web/pages/g/e/page_meta.py libervia/web/pages/g/page_meta.py libervia/web/pages/lists/_browser/__init__.py libervia/web/pages/lists/create/page_meta.py libervia/web/pages/lists/create_from_tpl/page_meta.py libervia/web/pages/lists/edit/page_meta.py libervia/web/pages/lists/new/page_meta.py libervia/web/pages/lists/page_meta.py libervia/web/pages/lists/view/_browser/__init__.py libervia/web/pages/lists/view/page_meta.py libervia/web/pages/lists/view_item/_browser/__init__.py libervia/web/pages/lists/view_item/page_meta.py libervia/web/pages/login/logged/page_meta.py libervia/web/pages/login/page_meta.py libervia/web/pages/merge-requests/disco/page_meta.py libervia/web/pages/merge-requests/edit/page_meta.py libervia/web/pages/merge-requests/new/page_meta.py libervia/web/pages/merge-requests/page_meta.py libervia/web/pages/merge-requests/view/page_meta.py libervia/web/pages/photos/_browser/__init__.py libervia/web/pages/photos/album/_browser/__init__.py libervia/web/pages/photos/album/page_meta.py libervia/web/pages/photos/new/page_meta.py libervia/web/pages/photos/page_meta.py libervia/web/pages/register/page_meta.py libervia/web/pages/u/atom.xml/page_meta.py libervia/web/pages/u/blog/page_meta.py libervia/web/pages/u/page_meta.py libervia/web/server/__init__.py libervia/web/server/classes.py libervia/web/server/constants.py libervia/web/server/html_tools.py libervia/web/server/launcher.py libervia/web/server/pages.py libervia/web/server/pages_tools.py libervia/web/server/proxy.py libervia/web/server/resources.py libervia/web/server/restricted_bridge.py libervia/web/server/server.py libervia/web/server/session_iface.py libervia/web/server/tasks/implicit/__init__.py libervia/web/server/tasks/implicit/task_brython.py libervia/web/server/tasks/implicit/task_js_modules.py libervia/web/server/tasks/implicit/task_sass.py libervia/web/server/tasks/manager.py libervia/web/server/tasks/task.py libervia/web/server/utils.py libervia/web/server/websockets.py setup.py twisted/plugins/libervia_server.py
diffstat 187 files changed, 12702 insertions(+), 12703 deletions(-) [+]
line wrap: on
line diff
--- a/doc/conf.py	Thu Jun 01 21:42:02 2023 +0200
+++ b/doc/conf.py	Fri Jun 02 16:49:28 2023 +0200
@@ -21,12 +21,12 @@
 
 # -- Project information -----------------------------------------------------
 
-project = u'Libervia (Salut à Toi)'
-copyright = u'2019-2021 Jérôme Poisson'
+project = u'Libervia Web'
+copyright = u'2019-2023 Jérôme Poisson'
 author = u'Jérôme Poisson'
 
 doc_dir = os.path.dirname(os.path.abspath(__file__))
-version_path = os.path.join(doc_dir, '../libervia/VERSION')
+version_path = os.path.join(doc_dir, '../libervia/web/VERSION')
 with open(version_path) as f:
     version_full = f.read()
 
--- a/libervia/VERSION	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1 +0,0 @@
-0.9.0D
--- a/libervia/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-import os.path
-
-version_file = os.path.join(os.path.dirname(__file__), "VERSION")
-with open(version_file) as f:
-    __version__ = f.read().strip()
--- a/libervia/common/constants.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: a SAT frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat_frontends.quick_frontend import constants
-import os.path
-
-
-class Const(constants.Const):
-
-    # XXX: we don't want to use the APP_VERSION inherited from sat.core.constants version
-    #      as we use this version to check that there is not a mismatch with backend
-    APP_VERSION = "0.9.0D"  # Please add 'D' at the end for dev versions
-    LIBERVIA_MAIN_PAGE = "libervia.html"
-    LIBERVIA_PAGE_START = "/login"
-
-    # REGISTRATION
-    # XXX: for now libervia forces the creation to lower case
-    # XXX: Regex patterns must be compatible with both Python and JS
-    REG_LOGIN_RE = r"^[a-z0-9_-]+$"
-    REG_EMAIL_RE = r"^.+@.+\..+"
-    PASSWORD_MIN_LENGTH = 6
-
-    # HTTP REQUEST RESULT VALUES
-    PROFILE_AUTH_ERROR = "PROFILE AUTH ERROR"
-    XMPP_AUTH_ERROR = "XMPP AUTH ERROR"
-    ALREADY_WAITING = "ALREADY WAITING"
-    SESSION_ACTIVE = "SESSION ACTIVE"
-    NOT_CONNECTED = "NOT CONNECTED"
-    PROFILE_LOGGED = "LOGGED"
-    PROFILE_LOGGED_EXT_JID = "LOGGED (REGISTERED WITH EXTERNAL JID)"
-    ALREADY_EXISTS = "ALREADY EXISTS"
-    INVALID_CERTIFICATE = "INVALID CERTIFICATE"
-    REGISTRATION_SUCCEED = "REGISTRATION"
-    INTERNAL_ERROR = "INTERNAL ERROR"
-    INVALID_INPUT = "INVALID INPUT"
-    BAD_REQUEST = "BAD REQUEST"
-    NO_REPLY = "NO REPLY"
-    NOT_ALLOWED = "NOT ALLOWED"
-    UPLOAD_OK = "UPLOAD OK"
-    UPLOAD_KO = "UPLOAD KO"
-
-    # directories
-    MEDIA_DIR = "media/"
-    CACHE_DIR = "cache"
-
-    # avatars
-    DEFAULT_AVATAR_FILE = "default_avatar.png"
-    DEFAULT_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", DEFAULT_AVATAR_FILE)
-    EMPTY_AVATAR_FILE = "empty_avatar"
-    EMPTY_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", EMPTY_AVATAR_FILE)
-
-    # blog
-    MAM_FILTER_CATEGORY = "http://salut-a-toi.org/protocols/mam_filter_category"
--- a/libervia/pages/_bridge/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,75 +0,0 @@
-#!/usr/bin/env python3
-
-import json
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from libervia.server.constants import Const as C
-
-
-log = getLogger(__name__)
-"""access to restricted bridge"""
-
-name = "bridge"
-on_data_post = "continue"
-
-# bridge method allowed when no profile is connected
-NO_SESSION_ALLOWED = ("contacts_get", "identities_base_get", "identities_get")
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["method_name"], min_args=1)
-
-
-async def render(self, request):
-    if request.method != b'POST':
-        log.warning(f"Bad method used with _bridge endpoint: {request.method.decode()}")
-        return self.page_error(request, C.HTTP_BAD_REQUEST)
-    data = self.get_r_data(request)
-    profile = self.get_profile(request)
-    self.check_csrf(request)
-    method_name = data["method_name"]
-    if profile is None:
-        if method_name in NO_SESSION_ALLOWED:
-            # this method is allowed, we use the service profile
-            profile = C.SERVICE_PROFILE
-        else:
-            log.warning("_bridge endpoint accessed without authorisation")
-            return self.page_error(request, C.HTTP_UNAUTHORIZED)
-    method_data = json.load(request.content)
-    try:
-        bridge_method = getattr(self.host.restricted_bridge, method_name)
-    except AttributeError:
-        log.warning(_(
-            "{profile!r} is trying to access a bridge method not implemented in "
-            "RestrictedBridge: {method_name}").format(
-                profile=profile, method_name=method_name))
-        return self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    try:
-        args, kwargs = method_data['args'], method_data['kwargs']
-    except KeyError:
-        log.warning(_(
-            "{profile!r} has sent a badly formatted method call: {method_data}"
-        ).format(profile=profile, method_data=method_data))
-        return self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    if "profile" in kwargs or "profile_key" in kwargs:
-        log.warning(_(
-            '"profile" key should not be in method kwargs, hack attempt? '
-            "profile={profile}, method_data={method_data}"
-        ).format(profile=profile, method_data=method_data))
-        return self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    try:
-        ret = await bridge_method(*args, **kwargs, profile=profile)
-    except BridgeException as e:
-        request.setResponseCode(C.HTTP_PROXY_ERROR)
-        ret = {
-            "fullname": e.fullname,
-            "message": e.message,
-            "condition": e.condition,
-            "module": e.module,
-            "classname": e.classname,
-        }
-    return json.dumps(ret)
--- a/libervia/pages/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-import bridge
-
-
-# we create a bridge instance to receive signals
-bridge.Bridge()
--- a/libervia/pages/_browser/alt_media_player.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,327 +0,0 @@
-#!/usr/bin/env python3
-
-"""This module implement an alternative media player
-
-If browser can't play natively some libre video/audio formats, ogv.js will be used,
-otherwise the native player will be used.
-
-This player uses its own controls, this allow better tuning/event handling notably with
-slideshow.
-"""
-
-from browser import document, timer, html
-
-
-NO_PAGINATION = "NO_PAGINATION"
-NO_SCROLLBAR = "NO_SCROLLBAR"
-
-
-class MediaPlayer:
-    TIMER_MODES = ("timer", "remaining")
-    # will be set to False if browser can't play natively webm or ogv
-    native = True
-    # will be set to True when template and modules will be imported
-    imports_done = False
-
-    def __init__(
-            self,
-            sources,
-            to_rpl_vid_elt=None,
-            poster=None,
-            reduce_click_area=False
-    ):
-        """
-        @param sources: list of paths to media
-            only the first one is used at the moment
-        @param to_rpl_vid_elt: video element to replace
-            if None, nothing is replaced and element must be inserted manually
-        @param reduce_click_area: when True, only center of the element will react to
-            click. Useful when used in slideshow, as click on border is used to
-            show/hide slide controls
-        """
-        self.do_imports()
-
-        self.reduce_click_area = reduce_click_area
-
-        self.media_player_elt = media_player_elt = media_player_tpl.get_elt()
-        self.player = player = self._create_player(sources, poster)
-        if to_rpl_vid_elt is not None:
-            to_rpl_vid_elt.parentNode.replaceChild(media_player_elt, to_rpl_vid_elt)
-        overlay_play_elt = self.media_player_elt.select_one(".media_overlay_play")
-        overlay_play_elt.bind("click", self.on_play_click)
-        self.progress_elt = media_player_elt.select_one("progress")
-        self.progress_elt.bind("click", self.on_progress_click)
-        self.timer_elt = media_player_elt.select_one(".timer")
-        self.timer_mode = "timer"
-
-        self.controls_elt = media_player_elt.select_one(".media_controls")
-        # we devnull 2 following events to avoid accidental side effect
-        # this is notably useful in slideshow to avoid changing the slide when
-        # the user misses slightly a button
-        self.controls_elt.bind("mousedown", self._devnull)
-        self.controls_elt.bind("click", self._devnull)
-
-        player_wrapper_elt = media_player_elt.select_one(".media_elt")
-        player.preload = "none"
-        player.src = sources[0]
-        player_wrapper_elt <= player
-        self.hide_controls_timer = None
-
-        # we capture mousedown to avoid side effect on slideshow
-        player_wrapper_elt.addEventListener("mousedown", self._devnull)
-        player_wrapper_elt.addEventListener("click", self.on_player_click)
-
-        # buttons
-        for handler in ("play", "change_timer_mode", "change_volume", "fullscreen"):
-            for elt in media_player_elt.select(f".click_to_{handler}"):
-                elt.bind("click", getattr(self, f"on_{handler}_click"))
-        # events
-        # FIXME: progress is not implemented in OGV.js, update when available
-        for event in ("play", "pause", "timeupdate", "ended", "volumechange"):
-            player.bind(event, getattr(self, f"on_{event}"))
-
-    @property
-    def elt(self):
-        return self.media_player_elt
-
-    def _create_player(self, sources, poster):
-        """Create player element, using native one when possible"""
-        player = None
-        if not self.native:
-            source = sources[0]
-            ext = self.get_source_ext(source)
-            if ext is None:
-                print(
-                    f"no extension found for {source}, using native player"
-                )
-            elif ext in self.cant_play_ext_list:
-                print(f"OGV player user for {source}")
-                player = self.ogv.OGVPlayer.new()
-                # OGCPlayer has non standard "poster" property
-                player.poster = poster
-        if player is None:
-            player = html.VIDEO(poster=poster)
-        return player
-
-    def reset(self):
-        """Put back media player in intial state
-
-        media will be stopped, time will be set to beginning, overlay will be put back
-        """
-        print("resetting media player")
-        self.player.pause()
-        self.player.currentTime = 0
-        self.media_player_elt.classList.remove("in_use")
-
-    def _devnull(self, evt):
-        # stop an event
-        evt.preventDefault()
-        evt.stopPropagation()
-
-    def on_player_click(self, evt):
-        if self.reduce_click_area:
-            bounding_rect = self.media_player_elt.getBoundingClientRect()
-            margin_x = margin_y = 200
-            if ((evt.clientX - bounding_rect.left < margin_x
-                 or bounding_rect.right - evt.clientX < margin_x
-                 or evt.clientY - bounding_rect.top < margin_y
-                 or bounding_rect.bottom - evt.clientY < margin_y
-               )):
-                # click is not in the center, we don't handle it and let the event
-                # propagate
-                return
-        self.on_play_click(evt)
-
-    def on_play_click(self, evt):
-        evt.preventDefault()
-        evt.stopPropagation()
-        self.media_player_elt.classList.add("in_use")
-        if self.player.paused:
-            print("playing")
-            self.player.play()
-        else:
-            self.player.pause()
-            print("paused")
-
-    def on_change_timer_mode_click(self, evt):
-        evt.preventDefault()
-        evt.stopPropagation()
-        self.timer_mode = self.TIMER_MODES[
-            (self.TIMER_MODES.index(self.timer_mode) + 1) % len(self.TIMER_MODES)
-        ]
-
-    def on_change_volume_click(self, evt):
-        evt.stopPropagation()
-        self.player.muted = not self.player.muted
-
-    def on_fullscreen_click(self, evt):
-        evt.stopPropagation()
-        try:
-            fullscreen_elt = document.fullscreenElement
-            request_fullscreen = self.media_player_elt.requestFullscreen
-        except AttributeError:
-            print("fullscreen is not available on this browser")
-        else:
-            if fullscreen_elt == None:
-                print("requesting fullscreen")
-                request_fullscreen()
-            else:
-                print(f"leaving fullscreen: {fullscreen_elt}")
-                try:
-                    document.exitFullscreen()
-                except AttributeError:
-                    print("exitFullscreen not available on this browser")
-
-    def on_progress_click(self, evt):
-        evt.stopPropagation()
-        position = evt.offsetX / evt.target.width
-        new_time = self.player.duration * position
-        self.player.currentTime = new_time
-
-    def on_play(self, evt):
-        self.media_player_elt.classList.add("playing")
-        self.show_controls()
-        self.media_player_elt.bind("mousemove", self.on_mouse_move)
-
-    def on_pause(self, evt):
-        self.media_player_elt.classList.remove("playing")
-        self.show_controls()
-        self.media_player_elt.unbind("mousemove")
-
-    def on_timeupdate(self, evt):
-        self.update_progress()
-
-    def on_ended(self, evt):
-        self.update_progress()
-
-    def on_volumechange(self, evt):
-        evt.stopPropagation()
-        if self.player.muted:
-            self.media_player_elt.classList.add("muted")
-        else:
-            self.media_player_elt.classList.remove("muted")
-
-    def on_mouse_move(self, evt):
-        self.show_controls()
-
-    def update_progress(self):
-        duration = self.player.duration
-        current_time = duration if self.player.ended else self.player.currentTime
-        self.progress_elt.max = duration
-        self.progress_elt.value = current_time
-        self.progress_elt.text = f"{current_time/duration*100:.02f}"
-        current_time, duration = int(current_time), int(duration)
-        if self.timer_mode == "timer":
-            cur_min, cur_sec = divmod(current_time, 60)
-            tot_min, tot_sec = divmod(duration, 60)
-            self.timer_elt.text = f"{cur_min}:{cur_sec:02d}/{tot_min}:{tot_sec:02d}"
-        elif self.timer_mode == "remaining":
-            rem_min, rem_sec = divmod(duration - current_time, 60)
-            self.timer_elt.text = f"{rem_min}:{rem_sec:02d}"
-        else:
-            print(f"ERROR: unknown timer mode: {self.timer_mode}")
-
-    def hide_controls(self):
-        self.controls_elt.classList.add("hidden")
-        self.media_player_elt.style.cursor = "none"
-        if self.hide_controls_timer is not None:
-            timer.clear_timeout(self.hide_controls_timer)
-            self.hide_controls_timer = None
-
-    def show_controls(self):
-        self.controls_elt.classList.remove("hidden")
-        self.media_player_elt.style.cursor = ""
-        if self.hide_controls_timer is not None:
-            timer.clear_timeout(self.hide_controls_timer)
-        if self.player.paused:
-            self.hide_controls_timer = None
-        else:
-            self.hide_controls_timer = timer.set_timeout(self.hide_controls, 3000)
-
-    @classmethod
-    def do_imports(cls):
-        # we do imports (notably for ogv.js) only if they are necessary
-        if cls.imports_done:
-            return
-        if not cls.native:
-            from js_modules import ogv
-            cls.ogv = ogv
-            if not ogv.OGVCompat.supported('OGVPlayer'):
-                print("Can't use OGVPlayer with this browser")
-                raise NotImplementedError
-        import template
-        global media_player_tpl
-        media_player_tpl = template.Template("components/media_player.html")
-        cls.imports_done = True
-
-    @staticmethod
-    def get_source_ext(source):
-        try:
-            ext = f".{source.rsplit('.', 1)[1].strip()}"
-        except IndexError:
-            return None
-        return ext or None
-
-    @classmethod
-    def install(cls, cant_play):
-        cls.native = False
-        ext_list = set()
-        for data in cant_play.values():
-            ext_list.update(data['ext'])
-        cls.cant_play_ext_list = ext_list
-        for to_rpl_vid_elt in document.body.select('video'):
-            sources = []
-            src = (to_rpl_vid_elt.src or '').strip()
-            if src:
-                sources.append(src)
-
-            for source_elt in to_rpl_vid_elt.select('source'):
-                src = (source_elt.src or '').strip()
-                if src:
-                    sources.append(src)
-
-            # FIXME: we only use first found source
-            try:
-                source = sources[0]
-            except IndexError:
-                print(f"Can't find any source for following elt:\n{to_rpl_vid_elt.html}")
-                continue
-
-            ext = cls.get_source_ext(source)
-
-            ext = f".{source.rsplit('.', 1)[1]}"
-            if ext is None:
-                print(
-                    "No extension found for source of following elt:\n"
-                    f"{to_rpl_vid_elt.html}"
-                )
-                continue
-            if ext in ext_list:
-                print(f"alternative player will be used for {source!r}")
-                cls(sources, to_rpl_vid_elt)
-
-
-def install_if_needed():
-    CONTENT_TYPES = {
-        "ogg_theora": {"type": 'video/ogg; codecs="theora"', "ext": [".ogv", ".ogg"]},
-        "webm_vp8": {"type": 'video/webm; codecs="vp8, vorbis"', "ext": [".webm"]},
-        "webm_vp9": {"type": 'video/webm; codecs="vp9"', "ext": [".webm"]},
-        # FIXME: handle audio
-        # "ogg_vorbis": {"type": 'audio/ogg; codecs="vorbis"', "ext": ".ogg"},
-    }
-    test_media_elt = html.VIDEO()
-    cant_play = {k:d for k,d in CONTENT_TYPES.items()
-                 if test_media_elt.canPlayType(d['type']) != "probably"}
-
-    if cant_play:
-        cant_play_list = '\n'.join(f"- {k} ({d['type']})" for k, d in cant_play.items())
-        print(
-            "This browser is incompatible with following content types, using "
-            f"alternative:\n{cant_play_list}"
-        )
-        try:
-            MediaPlayer.install(cant_play)
-        except NotImplementedError:
-            pass
-    else:
-        print("This browser can play natively all requested open video/audio formats")
--- a/libervia/pages/_browser/bridge.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,348 +0,0 @@
-from browser import window, aio, timer, console as log
-import time
-import random
-import json
-import dialog
-import javascript
-
-
-log.warning = log.warn
-tab_id = random.randint(0, 2**64)
-log.info(f"TAB ID is {tab_id}")
-
-
-class BridgeException(Exception):
-    """An exception which has been raised from the backend and arrived to the frontend."""
-
-    def __init__(self, name, message="", condition=""):
-        """
-
-        @param name (str): full exception class name (with module)
-        @param message (str): error message
-        @param condition (str) : error condition
-        """
-        Exception.__init__(self)
-        self.fullname = str(name)
-        self.message = str(message)
-        self.condition = str(condition) if condition else ""
-        self.module, __, self.classname = str(self.fullname).rpartition(".")
-
-    def __str__(self):
-        return f"{self.classname}: {self.message or ''}"
-
-    def __eq__(self, other):
-        return self.classname == other
-
-
-class WebSocket:
-
-    def __init__(self, broadcast_channel):
-        self.broadcast_channel = broadcast_channel
-        self.token = window.ws_token
-        self.create_socket()
-        self.retrying = False
-        self.network_error = False
-
-    @property
-    def profile(self):
-        return self.broadcast_channel.profile
-
-    def retry_connect(self) -> None:
-        if self.retrying:
-            return
-        self.retrying = True
-        try:
-            notif = dialog.RetryNotification(self.create_socket)
-            notif.show(
-                "Can't connect to server",
-                delay=random.randint(0, 30)
-            )
-        except Exception as e:
-            # for security reasons, browser don't give the reason of the error with
-            # WebSockets, thus we try to detect network error here, as if we can't show
-            # the retry dialog, that probably means that it's not reachable
-            try:
-                name = e.name
-            except AttributeError:
-                name = None
-            if name == "NetworkError":
-                self.network_error = True
-                log.warning("network error detected, server may be down")
-                log.error(f"Can't show retry dialog: {e}")
-                log.info("retrying in 30s")
-                timer.set_timeout(self.create_socket, 30000)
-            else:
-                raise e
-        else:
-            # if we can show the retry dialog, the network is fine
-            self.network_error = False
-
-    def create_socket(self) -> None:
-        log.debug("creating socket")
-        self.retrying = False
-        self.socket = window.WebSocket.new(window.ws_url, "libervia-page")
-        self.socket_start = time.time()
-        self.socket.bind("open", self.on_open)
-        self.socket.bind("error", self.on_error)
-        self.socket.bind("close", self.on_close)
-        self.socket.bind("message", self.on_message)
-
-    def send(self, data_type: str, data: dict) -> None:
-        self.socket.send(json.dumps({
-            "type": data_type,
-            "data": data
-        }))
-
-    def close(self) -> None:
-        log.debug("closing socket")
-        self.broadcast_channel.ws = None
-        self.socket.close()
-
-    def on_open(self, evt) -> None:
-        log.info("websocket connection opened")
-        self.send("init", {"profile": self.profile, "token": self.token})
-
-    def on_error(self, evt) -> None:
-        if not self.network_error and time.time() - self.socket_start < 5:
-            # disconnection is happening fast, we try to reload
-            log.warning("Reloading due to suspected session error")
-            window.location.reload()
-        else:
-            self.retry_connect()
-
-    def on_close(self, evt) -> None:
-        log.warning(f"websocket is closed {evt.code=} {evt.reason=}")
-        if self.broadcast_channel.ws is None:
-            # this is a close requested locally
-            return
-        elif evt.code == 4401:
-            log.info(
-                "no authorized, the session is probably not valid anymore, reloading"
-            )
-            window.location.reload()
-        else:
-            # close event may be due to normal tab closing, thus we try to reconnect only
-            # after a delay
-            timer.set_timeout(self.retry_connect, 5000)
-
-    def on_message(self, message_evt):
-        msg_data = json.loads(message_evt.data)
-        msg_type = msg_data.get("type")
-        if msg_type == "bridge":
-            log.debug(
-                f"==> bridge message: {msg_data=}"
-            )
-            self.broadcast_channel.post(
-                msg_type,
-                msg_data["data"]
-            )
-        elif msg_type == "force_close":
-            log.warning(f"force closing connection: {msg_data.get('reason')}")
-            self.close()
-        else:
-            dialog.notification.show(
-                f"Unexpected message type {msg_type}"
-                "error"
-            )
-
-
-class BroadcastChannel:
-    handlers = {}
-
-    def __init__(self):
-        log.debug(f"BroadcastChannel init with profile {self.profile!r}")
-        self.start = time.time()
-        self.bc = window.BroadcastChannel.new("libervia")
-        self.bc.bind("message", self.on_message)
-        # there is no way to check if there is already a connection in BroadcastChannel
-        # API, thus we wait a bit to see if somebody is answering. If not, we are probably
-        # the first tab.
-        self.check_connection_timer = timer.set_timeout(self.establish_connection, 20)
-        self.ws = None
-        # set of all known tab ids
-        self.tabs_ids = {tab_id}
-        self.post("salut_a_vous", {
-            "id": tab_id,
-            "profile": self.profile
-        })
-        window.bind("unload", self.on_unload)
-
-    @property
-    def profile(self):
-        return window.profile or ""
-
-    @property
-    def connecting_tab(self) -> bool:
-        """True is this tab is the one establishing the websocket connection"""
-        return self.ws is not None
-
-    @connecting_tab.setter
-    def connecting_tab(self, connecting: bool) -> None:
-        if connecting:
-            if self.ws is None:
-                self.ws = WebSocket(self)
-                self.post("connection", {
-                    "tab_id": tab_id
-                })
-        elif self.ws is not None:
-            self.ws.close()
-
-    def establish_connection(self) -> None:
-        """Called when there is no existing connection"""
-        timer.clear_timeout(self.check_connection_timer)
-        log.debug(f"Establishing connection {tab_id=}")
-        self.connecting_tab = True
-
-    def handle_bridge_signal(self, data: dict) -> None:
-        """Forward bridge signals to registered handlers"""
-        signal = data["signal"]
-        handlers = self.handlers.get(signal, [])
-        for handler in handlers:
-            handler(*data["args"])
-
-    def on_message(self, evt) -> None:
-        data = json.loads(evt.data)
-        if data["type"] == "bridge":
-            self.handle_bridge_signal(data)
-        elif data["type"] == "salut_a_toi":
-            # this is a response from existing tabs
-            other_tab_id = data["id"]
-            if other_tab_id == tab_id:
-                # in the unlikely case that this happens, we simply reload this tab to get
-                # a new ID
-                log.warning("duplicate tab id, we reload the page: {tab_id=}")
-                window.location.reload()
-                return
-            self.tabs_ids.add(other_tab_id)
-            if data["connecting_tab"] and self.check_connection_timer is not None:
-                # this tab has the websocket connection to server
-                log.info(f"there is already a connection to server at tab {other_tab_id}")
-                timer.clear_timeout(self.check_connection_timer)
-                self.check_connection_timer = None
-        elif data["type"] == "salut_a_vous":
-            # a new tab has just been created
-            if data["profile"] != self.profile:
-                log.info(
-                    f"we are now connected with the profile {data['profile']}, "
-                    "reloading the page"
-                )
-                window.location.reload()
-            else:
-                self.tabs_ids.add(data["id"])
-                self.post("salut_a_toi", {
-                    "id": tab_id,
-                    "connecting_tab": self.connecting_tab
-                })
-        elif data["type"] == "connection":
-            log.info(f"tab {data['id']} is the new connecting tab")
-        elif data["type"] == "salut_a_rantanplan":
-            # a tab is being closed
-            other_tab_id = data["id"]
-            # it is unlikely that there is a collision, but just in case we check it
-            if other_tab_id != tab_id:
-                self.tabs_ids.discard(other_tab_id)
-            if data["connecting_tab"]:
-                log.info(f"connecting tab with id {other_tab_id} has been closed")
-                if max(self.tabs_ids) == tab_id:
-                    log.info("this is the new connecting tab, establish_connection")
-                    self.connecting_tab = True
-            else:
-                log.info(f"tab with id {other_tab_id} has been closed")
-        else:
-            log.warning(f"unknown message type: {data}")
-
-    def post(self, data_type, data: dict):
-        data["type"] = data_type
-        data["id"] = tab_id
-        self.bc.postMessage(json.dumps(data))
-        if data_type == "bridge":
-            self.handle_bridge_signal(data)
-
-    def on_unload(self, evt) -> None:
-        """Send a message to indicate that the tab is being closed"""
-        self.post("salut_a_rantanplan", {
-            "id": tab_id,
-            "connecting_tab": self.connecting_tab
-        })
-
-
-class Bridge:
-    bc: BroadcastChannel | None = None
-
-    def __init__(self) -> None:
-        if Bridge.bc is None:
-            Bridge.bc = BroadcastChannel()
-
-    def __getattr__(self, attr):
-        return lambda *args, **kwargs: self.call(attr, *args, **kwargs)
-
-    def on_load(self, xhr, ev, callback, errback):
-        if xhr.status == 200:
-            ret = javascript.JSON.parse(xhr.response)
-            if callback is not None:
-                if ret is None:
-                    callback()
-                else:
-                    callback(ret)
-        elif xhr.status == 502:
-            # PROXY_ERROR is used for bridge error
-            ret = javascript.JSON.parse(xhr.response)
-            if errback is not None:
-                errback(ret)
-        else:
-            log.error(
-                f"bridge call failed: code: {xhr.response}, text: {xhr.statusText}"
-            )
-            if errback is not None:
-                errback({"fullname": "BridgeInternalError", "message": xhr.statusText})
-
-    def call(self, method_name, *args, callback, errback, **kwargs):
-        xhr = window.XMLHttpRequest.new()
-        xhr.bind('load', lambda ev: self.on_load(xhr, ev, callback, errback))
-        xhr.bind('error', lambda ev: errback(
-            {"fullname": "ConnectionError", "message": xhr.statusText}))
-        xhr.open("POST", f"/_bridge/{method_name}", True)
-        data = javascript.JSON.stringify({
-            "args": args,
-            "kwargs": kwargs,
-        })
-        xhr.setRequestHeader('X-Csrf-Token', window.csrf_token)
-        xhr.send(data)
-
-    def register_signal(self, signal: str, handler, iface=None) -> None:
-        BroadcastChannel.handlers.setdefault(signal, []).append(handler)
-        log.debug(f"signal {signal} has been registered")
-
-
-class AsyncBridge:
-
-    def __getattr__(self, attr):
-        return lambda *args, **kwargs: self.call(attr, *args, **kwargs)
-
-    async def call(self, method_name, *args, **kwargs):
-        print(f"calling {method_name}")
-        data = javascript.JSON.stringify({
-            "args": args,
-            "kwargs": kwargs,
-        })
-        url = f"/_bridge/{method_name}"
-        r = await aio.post(
-            url,
-            headers={
-                'X-Csrf-Token': window.csrf_token,
-            },
-            data=data,
-        )
-
-        if r.status == 200:
-            return javascript.JSON.parse(r.data)
-        elif r.status == 502:
-            ret = javascript.JSON.parse(r.data)
-            raise BridgeException(ret['fullname'], ret['message'], ret['condition'])
-        else:
-            print(f"bridge called failed: code: {r.status}, text: {r.statusText}")
-            raise BridgeException("InternalError", r.statusText)
-
-    def register_signal(self, signal: str, handler, iface=None) -> None:
-        BroadcastChannel.handlers.setdefault(signal, []).append(handler)
-        log.debug(f"signal {signal} has been registered")
--- a/libervia/pages/_browser/browser_meta.json	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-{
-    "js": {
-        "package": {
-            "dependencies": {
-                "nunjucks": "^3.2.3",
-                "swiper": "^6.8.4",
-                "moment": "^2.29.1",
-                "ogv": "^1.8.4"
-            }
-        },
-        "brython_map": {
-            "nunjucks": "nunjucks/browser/nunjucks.min.js",
-            "swiper": {
-                "path": "swiper/swiper-bundle.min.js",
-                "export": ["Swiper"]
-            },
-            "moment": "moment/min/moment.min.js",
-            "ogv": {
-                "path": "ogv/dist/ogv.js",
-                "export": ["OGVCompat", "OGVLoader", "OGVMediaError", "OGVMediaType", "OGVTimeRanges", "OGVPlayer", "OGVVersion"],
-                "extra_init": "OGVLoader.base='/{build_dir}/node_modules/ogv/dist'"
-            }
-        }
-    }
-}
--- a/libervia/pages/_browser/cache.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,156 +0,0 @@
-from browser import window
-from browser.local_storage import storage
-from javascript import JSON
-from dialog import notification
-from bridge import Bridge
-
-session_uuid = window.session_uuid
-bridge = Bridge()
-
-# XXX: we don't use browser.object_storage because it is affected by
-#   https://github.com/brython-dev/brython/issues/1467 and mixing local_storage.storage
-#   and object_storage was resulting in weird behaviour (keys found in one not in the
-#   other)
-
-
-class Cache:
-
-    def __init__(self):
-        try:
-            cache = storage['libervia_cache']
-        except KeyError:
-            self.request_data_from_backend()
-        else:
-            cache = JSON.parse(cache)
-            if cache['metadata']['session_uuid'] != session_uuid:
-                print("data in cache are not valid for this session, resetting")
-                del storage['libervia_cache']
-                self.request_data_from_backend()
-            else:
-                self._cache = cache
-                print("storage cache is used")
-
-    @property
-    def roster(self):
-        return self._cache['roster']
-
-    @property
-    def identities(self):
-        return self._cache['identities']
-
-    def update(self):
-        # FIXME: we use window.JSON as a workaround to
-        #   https://github.com/brython-dev/brython/issues/1467
-        print(f"updating: {self._cache}")
-        storage['libervia_cache'] = window.JSON.stringify(self._cache)
-        print("cache stored")
-
-    def _store_if_complete(self):
-        self._completed_count -= 1
-        if self._completed_count == 0:
-            del self._completed_count
-            self.update()
-
-    def get_contacts_cb(self, contacts):
-        print("roster received")
-        roster = self._cache['roster']
-        for contact_jid, attributes, groups in contacts:
-            roster[contact_jid] = {
-                'attributes': attributes,
-                'groups': groups,
-            }
-        self._store_if_complete()
-
-    def identities_base_get_cb(self, identities_raw):
-        print("base identities received")
-        identities = JSON.parse(identities_raw)
-        self._cache['identities'].update(identities)
-        self._store_if_complete()
-
-    def request_failed(self, exc, message):
-        notification.show(message.format(exc=exc), "error")
-        self._store_if_complete()
-
-    def request_data_from_backend(self):
-        self._cache = {
-            'metadata': {
-                "session_uuid": session_uuid,
-            },
-            'roster': {},
-            'identities': {},
-        }
-        self._completed_count = 2
-        print("requesting roster to backend")
-        bridge.contacts_get(
-            callback=self.get_contacts_cb,
-            errback=lambda e: self.request_failed(e, "Can't get contacts: {exc}")
-        )
-        print("requesting base identities to backend")
-        bridge.identities_base_get(
-            callback=self.identities_base_get_cb,
-            errback=lambda e: self.request_failed(e, "Can't get base identities: {exc}")
-        )
-
-    def _fill_identities_cb(self, new_identities_raw, callback):
-        new_identities = JSON.parse(new_identities_raw)
-        print(f"new identities: {new_identities.keys()}")
-        self._cache['identities'].update(new_identities)
-        self.update()
-        if callback:
-            callback()
-
-    def fill_identities(self, entities, callback=None):
-        """Check that identities for entities exist, request them otherwise"""
-        to_get = {e for e in entities if e not in self._cache['identities']}
-        if to_get:
-            bridge.identities_get(
-                list(to_get),
-                ['avatar', 'nicknames'],
-                callback=lambda identities: self._fill_identities_cb(
-                    identities, callback),
-                errback=lambda failure_: notification.show(
-                    f"Can't get identities: {failure_}",
-                    "error"
-                )
-            )
-        else:
-            # we already have all identities
-            print("no missing identity")
-            if callback:
-                callback()
-
-    def match_identity(self, entity_jid, text, identity=None):
-        """Returns True if a text match an entity identity
-
-        identity will be matching if its jid or any of its name contain text
-        @param entity_jid: jid of the entity to check
-        @param text: text to use for filtering. Must be in lowercase and stripped
-        @param identity: identity data
-            if None, it will be retrieved if jid is not matching
-        @return: True if entity is matching
-        """
-        if text in entity_jid:
-            return True
-        if identity is None:
-            try:
-                identity = self.identities[entity_jid]
-            except KeyError:
-                print(f"missing identity: {entity_jid}")
-                return False
-        return any(text in n.lower() for n in identity['nicknames'])
-
-    def matching_identities(self, text):
-        """Return identities corresponding to a text
-
-        """
-        text = text.lower().strip()
-        for entity_jid, identity in self._cache['identities'].items():
-            if ((text in entity_jid
-                 or any(text in n.lower() for n in identity['nicknames'])
-                 )):
-                yield entity_jid
-
-
-cache = Cache()
-roster = cache.roster
-identities = cache.identities
--- a/libervia/pages/_browser/dialog.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,131 +0,0 @@
-"""manage common dialogs"""
-
-from browser import document, window, timer, console as log
-from template import Template
-
-log.warning = log.warn
-
-
-class Confirm:
-
-    def __init__(self, message, ok_label="", cancel_label="", ok_color="success"):
-        self._tpl = Template("dialogs/confirm.html")
-        self.message = message
-        self.ok_label = ok_label
-        assert ok_color in ("success", "danger")
-        self.ok_color = ok_color
-        self.cancel_label = cancel_label
-
-    def cancel_cb(self, evt, notif_elt):
-        notif_elt.remove()
-
-    def show(self, ok_cb, cancel_cb=None):
-        if cancel_cb is None:
-            cancel_cb = self.cancel_cb
-        notif_elt = self._tpl.get_elt({
-            "message": self.message,
-            "ok_label": self.ok_label,
-            "ok_color": self.ok_color,
-            "cancel_label": self.cancel_label,
-        })
-
-        document['notifs_area'] <= notif_elt
-        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
-        for cancel_elt in notif_elt.select(".click_to_cancel"):
-            cancel_elt.bind("click", lambda evt: cancel_cb(evt, notif_elt))
-        for cancel_elt in notif_elt.select(".click_to_ok"):
-            cancel_elt.bind("click", lambda evt: ok_cb(evt, notif_elt))
-
-    def _ashow_cb(self, evt, notif_elt, resolve_cb, confirmed):
-        evt.stopPropagation()
-        notif_elt.remove()
-        resolve_cb(confirmed)
-
-    async def ashow(self):
-        return window.Promise.new(
-            lambda resolve_cb, reject_cb:
-            self.show(
-                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, True),
-                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, False)
-            )
-        )
-
-
-class Notification:
-
-    def __init__(self):
-        self._tpl = Template("dialogs/notification.html")
-
-    def close(self, notif_elt):
-        notif_elt.classList.remove('state_appended')
-        notif_elt.bind("transitionend", lambda __: notif_elt.remove())
-
-    def show(
-        self,
-        message: str,
-        level: str = "info",
-        delay: int = 5
-    ) -> None:
-        # we log in console error messages, may be useful
-        if level in ("warning", "error"):
-            print(f"[{level}] {message}")
-        notif_elt = self._tpl.get_elt({
-            "message": message,
-            "level": level,
-        })
-        document["notifs_area"] <= notif_elt
-        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
-        timer.set_timeout(lambda: self.close(notif_elt), delay * 1000)
-        for elt in notif_elt.select('.click_to_close'):
-            elt.bind('click', lambda __: self.close(notif_elt))
-
-
-class RetryNotification:
-    def __init__(self, retry_cb):
-        self._tpl = Template("dialogs/retry-notification.html")
-        self.retry_cb = retry_cb
-        self.counter = 0
-        self.timer = None
-
-    def retry(self, notif_elt):
-        if self.timer is not None:
-            timer.clear_interval(self.timer)
-            self.timer = None
-        notif_elt.classList.remove('state_appended')
-        notif_elt.bind("transitionend", lambda __: notif_elt.remove())
-        self.retry_cb()
-
-    def update_counter(self, notif_elt):
-        counter = notif_elt.select_one(".retry_counter")
-        counter.text = str(self.counter)
-        self.counter -= 1
-        if self.counter < 0:
-            self.retry(notif_elt)
-
-    def show(
-        self,
-        message: str,
-        level: str = "warning",
-        delay: int = 5
-    ) -> None:
-        # we log in console error messages, may be useful
-        if level == "error":
-            log.error(message)
-        elif level == "warning":
-            log.warning(message)
-        self.counter = delay
-        notif_elt = self._tpl.get_elt({
-            "message": message,
-            "level": level,
-        })
-        self.update_counter(notif_elt)
-        document["notifs_area"] <= notif_elt
-        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
-        self.timer = timer.set_interval(self.update_counter, 1000, notif_elt)
-        for elt in notif_elt.select('.click_to_retry'):
-            elt.bind('click', lambda __: self.retry(notif_elt))
-
-
-
-
-notification = Notification()
--- a/libervia/pages/_browser/editor.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,234 +0,0 @@
-"""text edition management"""
-
-from browser import document, window, aio, bind, timer
-from browser.local_storage import storage
-from browser.object_storage import ObjectStorage
-from javascript import JSON
-from bridge import AsyncBridge as Bridge, BridgeException
-from template import Template
-import dialog
-
-bridge = Bridge()
-object_storage = ObjectStorage(storage)
-profile = window.profile
-
-# how often we save forms, in seconds
-AUTOSAVE_FREQUENCY = 20
-
-
-def serialise_form(form_elt):
-    ret = {}
-    for elt in form_elt.elements:
-        if elt.tagName == "INPUT":
-            if elt.type in ("hidden", "submit"):
-                continue
-            elif elt.type == "text":
-                ret[elt.name] = elt.value
-            else:
-                print(f"elt.type not managet yet: {elt.type}")
-                continue
-        elif elt.tagName == "TEXTAREA":
-            ret[elt.name] = elt.value
-        elif elt.tagName in ("BUTTON",):
-            continue
-        else:
-            print(f"tag not managet yet: {elt.tagName}")
-            continue
-    return ret
-
-
-def restore_form(form_elt, data):
-    for elt in form_elt.elements:
-        if elt.tagName not in ("INPUT", "TEXTAREA"):
-            continue
-        try:
-            value = data[elt.name]
-        except KeyError:
-            continue
-        else:
-            elt.value = value
-
-
-def set_form_autosave(form_id):
-    """Save locally form data regularly and restore it until it's submitted
-
-    form is saved every AUTOSAVE_FREQUENCY seconds and when visibility is lost.
-    Saved data is restored when the method is called.
-    Saved data is cleared when the form is submitted.
-    """
-    if profile is None:
-        print(f"No session started, won't save and restore form {form_id}")
-        return
-
-    form_elt = document[form_id]
-    submitted = False
-
-    key = {"profile": profile, "type": "form_autosave", "form": form_id}
-    try:
-        form_saved_data = object_storage[key]
-    except KeyError:
-        last_serialised = None
-    else:
-        print(f"restoring content of form {form_id!r}")
-        last_serialised = form_saved_data
-        restore_form(form_elt, form_saved_data)
-
-    def save_form():
-        if not submitted:
-            nonlocal last_serialised
-            serialised = serialise_form(form_elt)
-            if serialised != last_serialised:
-                last_serialised = serialised
-                print(f"saving content of form {form_id!r}")
-                object_storage[key] = serialised
-
-    @bind(form_elt, "submit")
-    def on_submit(evt):
-        nonlocal submitted
-        submitted = True
-        print(f"clearing stored content of form {form_id!r}")
-        try:
-            del object_storage[key]
-        except KeyError:
-            print("key error")
-            pass
-
-    @bind(document, "visibilitychange")
-    def on_visibiliy_change(evt):
-        print("visibility change")
-        if document.visibilityState != "visible":
-            save_form()
-
-    timer.set_interval(save_form, AUTOSAVE_FREQUENCY * 1000)
-
-
-class TagsEditor:
-
-    def __init__(self, input_selector):
-        print("installing Tags Editor")
-        self.input_elt = document.select_one(input_selector)
-        self.input_elt.style.display = "none"
-        tags_editor_tpl = Template('editor/tags_editor.html')
-        self.tag_tpl = Template('editor/tag.html')
-
-        editor_elt = tags_editor_tpl.get_elt()
-        self.input_elt.parent <= editor_elt
-        self.tag_input_elt = editor_elt.select_one(".tag_input")
-        self.tag_input_elt.bind("keydown", self.on_key_down)
-        self._current_tags = None
-        self.tags_map = {}
-        for tag in self.current_tags:
-            self.add_tag(tag, force=True)
-
-    @property
-    def current_tags(self):
-        if self._current_tags is None:
-            self._current_tags = {
-                t.strip() for t in self.input_elt.value.split(',') if t.strip()
-            }
-        return self._current_tags
-
-    @current_tags.setter
-    def current_tags(self, tags):
-        self._current_tags = tags
-
-    def add_tag(self, tag, force=False):
-        tag = tag.strip()
-        if not force and (not tag or tag in self.current_tags):
-            return
-        self.current_tags = self.current_tags | {tag}
-        self.input_elt.value = ','.join(self.current_tags)
-        tag_elt = self.tag_tpl.get_elt({"label": tag})
-        self.tags_map[tag] = tag_elt
-        self.tag_input_elt.parent.insertBefore(tag_elt, self.tag_input_elt)
-        tag_elt.select_one(".click_to_delete").bind(
-            "click", lambda evt: self.on_tag_click(evt, tag)
-        )
-
-    def remove_tag(self, tag):
-        try:
-            tag_elt = self.tags_map[tag]
-        except KeyError:
-            print(f"trying to remove an inexistant tag: {tag}")
-        else:
-            self.current_tags = self.current_tags - {tag}
-            self.input_elt.value = ','.join(self.current_tags)
-            tag_elt.remove()
-
-    def on_tag_click(self, evt, tag):
-        evt.stopPropagation()
-        self.remove_tag(tag)
-
-    def on_key_down(self, evt):
-        if evt.key in (",", "Enter"):
-            evt.stopPropagation()
-            evt.preventDefault()
-            self.add_tag(self.tag_input_elt.value)
-            self.tag_input_elt.value = ""
-
-
-class BlogEditor:
-    """Editor class, handling tabs, preview, and submit loading button
-
-    It's using and HTML form as source
-    The form must have:
-        - a "title" text input
-        - a "body" textarea
-        - an optional "tags" text input with comma separated tags (may be using Tags
-          Editor)
-        - a "tab_preview" tab element
-    """
-
-    def __init__(self, form_id="blog_post_edit"):
-        self.tab_select = window.tab_select
-        self.item_tpl = Template('blog/item.html')
-        self.form = document[form_id]
-        for elt in document.select(".click_to_edit"):
-            elt.bind("click", self.on_edit)
-        for elt in document.select('.click_to_preview'):
-            elt.bind("click", lambda evt: aio.run(self.on_preview(evt)))
-        self.form.bind("submit", self.on_submit)
-
-
-    def on_edit(self, evt):
-        self.tab_select(evt.target, "tab_edit", "is-active")
-
-    async def on_preview(self, evt):
-        """Generate a blog preview from HTML form
-
-        """
-        print("on preview OK")
-        elt = evt.target
-        tab_preview = document["tab_preview"]
-        tab_preview.clear()
-        data = {
-            "content_rich": self.form.select_one('textarea[name="body"]').value.strip()
-        }
-        title = self.form.select_one('input[name="title"]').value.strip()
-        if title:
-            data["title_rich"] = title
-        tags_input_elt = self.form.select_one('input[name="tags"]')
-        if tags_input_elt is not None:
-            tags = tags_input_elt.value.strip()
-            if tags:
-                data['tags'] = [t.strip() for t in tags.split(',') if t.strip()]
-        try:
-            preview_data = JSON.parse(
-                await bridge.mb_preview("", "", JSON.stringify(data))
-            )
-        except BridgeException as e:
-            dialog.notification.show(
-                f"Can't generate item preview: {e.message}",
-                level="error"
-            )
-        else:
-            self.tab_select(elt, "tab_preview", "is-active")
-            item_elt = self.item_tpl.get_elt({
-                "item": preview_data,
-                "dates_format": "short",
-            })
-            tab_preview <= item_elt
-
-    def on_submit(self, evt):
-        submit_btn = document.select_one("button[type='submit']")
-        submit_btn.classList.add("is-loading")
--- a/libervia/pages/_browser/errors.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,2 +0,0 @@
-class TimeoutError(Exception):
-    """An action has not been done in time"""
--- a/libervia/pages/_browser/invitation.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,444 +0,0 @@
-from browser import document, window, timer
-from bridge import Bridge
-from template import Template
-import dialog
-from cache import cache
-import javascript
-
-bridge = Bridge()
-# we use JS RegExp because Python's re is really long to import in Brython
-# FIXME: this is a naive JID regex, a more accurate should be used instead
-jid_re = javascript.RegExp.new(r"^\w+@\w+\.\w+")
-
-
-class InvitationManager:
-
-    def __init__(self, invitation_type, invitation_data):
-        self.invitation_type = invitation_type
-        self.invitation_data = invitation_data
-        manager_panel_tpl = Template('invitation/manager.html')
-        self.manager_panel_elt = manager_panel_tpl.get_elt()
-        self.invite_by_email_tpl = Template('invitation/invite_by_email.html')
-        self.affiliation_tpl = Template('invitation/affiliation_item.html')
-        self.new_item_tpl = Template('invitation/new_item.html')
-        # list of item passing filter when adding a new contact
-        self._filtered_new_items = {}
-        self._active_new_item = None
-        self._idx = 0
-
-    def attach(self, affiliations=None):
-        if affiliations is None:
-            affiliations = {}
-        self.affiliations = affiliations
-        self.side_panel = self.manager_panel_elt.select_one(
-            '.invitation_manager_side_panel')
-        self.open()
-        for close_elt in self.manager_panel_elt.select('.click_to_close'):
-            close_elt.bind("click", self.on_manager_close)
-        self.side_panel.bind("click", lambda evt: evt.stopPropagation())
-
-        cache.fill_identities(affiliations.keys(), callback=self._set_affiliations)
-
-        contact_elt = self.manager_panel_elt.select_one('input[name="contact"]')
-        contact_elt.bind("input", self.on_contact_input)
-        contact_elt.bind("keydown", self.on_contact_keydown)
-        contact_elt.bind("focus", self.on_contact_focus)
-        contact_elt.bind("blur", self.on_contact_blur)
-        document['invite_email'].bind('click', self.on_invite_email_click)
-
-    def _set_affiliations(self):
-        for entity_jid, affiliation in self.affiliations.items():
-            self.set_affiliation(entity_jid, affiliation)
-
-    def open(self):
-        """Re-attach and show a closed panel"""
-        self._body_ori_style = document.body.style.height, document.body.style.overflow
-        document.body.style.height = '100vh'
-        document.body.style.overflow = 'hidden'
-        document.body <= self.manager_panel_elt
-        timer.set_timeout(lambda: self.side_panel.classList.add("open"), 0)
-
-    def _on_close_transition_end(self, evt):
-        self.manager_panel_elt.remove()
-        # FIXME: not working with Brython, to report upstream
-        # self.side_panel.unbind("transitionend", self._on_close_transition_end)
-        self.side_panel.unbind("transitionend")
-
-    def close(self):
-        """Hide the panel"""
-        document.body.style.height, document.body.style.overflow = self._body_ori_style
-        self.side_panel.classList.remove('open')
-        self.side_panel.bind("transitionend", self._on_close_transition_end)
-
-    def _invite_jid(self, entity_jid, callback, errback=None):
-        if errback is None:
-            errback = lambda e: dialog.notification.show(f"invitation failed: {e}", "error")
-        if self.invitation_type == 'photos':
-            service = self.invitation_data["service"]
-            path = self.invitation_data["path"]
-            album_name = path.rsplit('/')[-1]
-            print(f"inviting {entity_jid}")
-            bridge.fis_invite(
-                entity_jid,
-                service,
-                "photos",
-                "",
-                path,
-                album_name,
-                '',
-                callback=callback,
-                errback=errback
-            )
-        elif self.invitation_type == 'pubsub':
-            service = self.invitation_data["service"]
-            node = self.invitation_data["node"]
-            name = self.invitation_data.get("name")
-            namespace = self.invitation_data.get("namespace")
-            extra = {}
-            if namespace:
-                extra["namespace"] = namespace
-            print(f"inviting {entity_jid}")
-            bridge.ps_invite(
-                entity_jid,
-                service,
-                node,
-                '',
-                name,
-                javascript.JSON.stringify(extra),
-                callback=callback,
-                errback=errback
-            )
-        else:
-            print(f"error: unknown invitation type: {self.invitation_type}")
-
-    def invite_by_jid(self, entity_jid):
-        self._invite_jid(
-            entity_jid,
-            callback=lambda entity_jid=entity_jid: self._on_jid_invitation_success(entity_jid),
-        )
-
-    def on_manager_close(self, evt):
-        self.close()
-
-    def _on_jid_invitation_success(self, entity_jid):
-        form_elt = document['invitation_form']
-        contact_elt = form_elt.select_one('input[name="contact"]')
-        contact_elt.value = ""
-        contact_elt.dispatchEvent(window.Event.new('input'))
-        dialog.notification.show(
-            f"{entity_jid} has been invited",
-            level="success",
-        )
-        if entity_jid not in self.affiliations:
-            self.set_affiliation(entity_jid, "member")
-
-    def on_contact_invite(self, evt, entity_jid):
-        """User is adding a contact"""
-        form_elt = document['invitation_form']
-        contact_elt = form_elt.select_one('input[name="contact"]')
-        contact_elt.value = ""
-        contact_elt.dispatchEvent(window.Event.new('input'))
-        self.invite_by_jid(entity_jid)
-
-    def on_contact_keydown(self, evt):
-        if evt.key == "Escape":
-            evt.target.value = ""
-            evt.target.dispatchEvent(window.Event.new('input'))
-        elif evt.key == "ArrowDown":
-            evt.stopPropagation()
-            evt.preventDefault()
-            content_elt = document['invitation_contact_search'].select_one(
-                ".search_dialog__content")
-            if self._active_new_item == None:
-                self._active_new_item = content_elt.firstElementChild
-                self._active_new_item.classList.add('selected')
-            else:
-                next_item = self._active_new_item.nextElementSibling
-                if next_item is not None:
-                    self._active_new_item.classList.remove('selected')
-                    self._active_new_item = next_item
-                    self._active_new_item.classList.add('selected')
-        elif evt.key == "ArrowUp":
-            evt.stopPropagation()
-            evt.preventDefault()
-            content_elt = document['invitation_contact_search'].select_one(
-                ".search_dialog__content")
-            if self._active_new_item == None:
-                self._active_new_item = content_elt.lastElementChild
-                self._active_new_item.classList.add('selected')
-            else:
-                previous_item = self._active_new_item.previousElementSibling
-                if previous_item is not None:
-                    self._active_new_item.classList.remove('selected')
-                    self._active_new_item = previous_item
-                    self._active_new_item.classList.add('selected')
-        elif evt.key == "Enter":
-            evt.stopPropagation()
-            evt.preventDefault()
-            if self._active_new_item is not None:
-                entity_jid = self._active_new_item.dataset.entityJid
-                self.invite_by_jid(entity_jid)
-            else:
-                if jid_re.exec(evt.target.value):
-                    self.invite_by_jid(evt.target.value)
-                    evt.target.value = ""
-
-    def on_contact_focus(self, evt):
-        search_dialog = document['invitation_contact_search']
-        search_dialog.classList.add('open')
-        self._active_new_item = None
-        evt.target.dispatchEvent(window.Event.new('input'))
-
-    def on_contact_blur(self, evt):
-        search_dialog = document['invitation_contact_search']
-        search_dialog.classList.remove('open')
-        for elt in self._filtered_new_items.values():
-            elt.remove()
-        self._filtered_new_items.clear()
-
-
-    def on_contact_input(self, evt):
-        text = evt.target.value.strip().lower()
-        search_dialog = document['invitation_contact_search']
-        content_elt = search_dialog.select_one(".search_dialog__content")
-        for (entity_jid, identity) in cache.identities.items():
-            if not cache.match_identity(entity_jid, text, identity):
-                # if the entity was present in last pass, we remove it
-                try:
-                    filtered_item = self._filtered_new_items.pop(entity_jid)
-                except KeyError:
-                    pass
-                else:
-                    filtered_item.remove()
-                continue
-            if entity_jid not in self._filtered_new_items:
-                # we only create a new element if the item was not already there
-                new_item_elt = self.new_item_tpl.get_elt({
-                    "entity_jid": entity_jid,
-                    "identities": cache.identities,
-                })
-                content_elt <= new_item_elt
-                self._filtered_new_items[entity_jid] = new_item_elt
-                for elt in new_item_elt.select('.click_to_ok'):
-                    # we use mousedown instead of click because otherwise it would be
-                    # ignored due to "blur" event manager (see
-                    # https://stackoverflow.com/a/9335401)
-                    elt.bind(
-                        "mousedown",
-                        lambda evt, entity_jid=entity_jid: self.on_contact_invite(
-                            evt, entity_jid),
-                    )
-
-        if ((self._active_new_item is not None
-             and not self._active_new_item.parentElement)):
-            # active item has been filtered out
-            self._active_new_item = None
-
-    def _on_email_invitation_success(self, invitee_jid, email, name):
-        self.set_affiliation(invitee_jid, "member")
-        dialog.notification.show(
-            f"{name} has been invited, he/she has received an email with a link",
-            level="success",
-        )
-
-    def invitation_simple_create_cb(self, invitation_data, email, name):
-        invitee_jid = invitation_data['jid']
-        self._invite_jid(
-            invitee_jid,
-            callback=lambda: self._on_email_invitation_success(invitee_jid, email, name),
-            errback=lambda e: dialog.notification.show(
-                f"invitation failed for {email}: {e}",
-                "error"
-            )
-        )
-
-        # we update identities to have the name instead of the invitation jid in
-        # affiliations
-        cache.identities[invitee_jid] = {'nicknames': [name]}
-        cache.update()
-
-    def invite_by_email(self, email, name):
-        guest_url_tpl = f'{window.URL.new("/g", document.baseURI).href}/{{uuid}}'
-        bridge.invitation_simple_create(
-            email,
-            name,
-            guest_url_tpl,
-            '',
-            callback=lambda data: self.invitation_simple_create_cb(data, email, name),
-            errback=lambda e: window.alert(f"can't send email invitation: {e}")
-        )
-
-    def on_invite_email_submit(self, evt, invite_email_elt):
-        evt.stopPropagation()
-        evt.preventDefault()
-        form = document['email_invitation_form']
-        try:
-            reportValidity = form.reportValidity
-        except AttributeError:
-            print("reportValidity is not supported by this browser!")
-        else:
-            if not reportValidity():
-                return
-        email = form.select_one('input[name="email"]').value
-        name = form.select_one('input[name="name"]').value
-        self.invite_by_email(email, name)
-        invite_email_elt.remove()
-        self.open()
-
-    def on_invite_email_close(self, evt, invite_email_elt):
-        evt.stopPropagation()
-        evt.preventDefault()
-        invite_email_elt.remove()
-        self.open()
-
-    def on_invite_email_click(self, evt):
-        evt.stopPropagation()
-        evt.preventDefault()
-        invite_email_elt = self.invite_by_email_tpl.get_elt()
-        document.body <= invite_email_elt
-        document['email_invitation_submit'].bind(
-            'click', lambda evt: self.on_invite_email_submit(evt, invite_email_elt)
-        )
-        for close_elt in invite_email_elt.select('.click_to_close'):
-            close_elt.bind(
-                "click", lambda evt: self.on_invite_email_close(evt, invite_email_elt))
-        self.close()
-
-    ## affiliations
-
-    def _add_affiliation_bindings(self, entity_jid, affiliation_elt):
-        for elt in affiliation_elt.select(".click_to_delete"):
-            elt.bind(
-                "click",
-                lambda evt, entity_jid=entity_jid, affiliation_elt=affiliation_elt:
-                self.on_affiliation_remove(entity_jid, affiliation_elt)
-            )
-        for elt in affiliation_elt.select(".click_to_set_publisher"):
-            try:
-                name = cache.identities[entity_jid]["nicknames"][0]
-            except (KeyError, IndexError):
-                name = entity_jid
-            elt.bind(
-                "click",
-                lambda evt, entity_jid=entity_jid, name=name,
-                    affiliation_elt=affiliation_elt:
-                    self.on_affiliation_set(
-                        entity_jid, name, affiliation_elt, "publisher"
-                    ),
-            )
-        for elt in affiliation_elt.select(".click_to_set_member"):
-            try:
-                name = cache.identities[entity_jid]["nicknames"][0]
-            except (KeyError, IndexError):
-                name = entity_jid
-            elt.bind(
-                "click",
-                lambda evt, entity_jid=entity_jid, name=name,
-                    affiliation_elt=affiliation_elt:
-                    self.on_affiliation_set(
-                        entity_jid, name, affiliation_elt, "member"
-                    ),
-            )
-
-    def set_affiliation(self, entity_jid, affiliation):
-        if affiliation not in ('owner', 'member', 'publisher'):
-            raise NotImplementedError(
-                f'{affiliation} affiliation can not be set with this method for the '
-                'moment')
-        if entity_jid not in self.affiliations:
-            self.affiliations[entity_jid] = affiliation
-        affiliation_elt = self.affiliation_tpl.get_elt({
-            "entity_jid": entity_jid,
-            "affiliation": affiliation,
-            "identities": cache.identities,
-        })
-        document['affiliations'] <= affiliation_elt
-        self._add_affiliation_bindings(entity_jid, affiliation_elt)
-
-    def _on_affiliation_remove_success(self, affiliation_elt, entity_jid):
-        affiliation_elt.remove()
-        del self.affiliations[entity_jid]
-
-    def on_affiliation_remove(self, entity_jid, affiliation_elt):
-        if self.invitation_type == 'photos':
-            path = self.invitation_data["path"]
-            service = self.invitation_data["service"]
-            bridge.fis_affiliations_set(
-                service,
-                "",
-                path,
-                {entity_jid: "none"},
-                callback=lambda: self._on_affiliation_remove_success(
-                    affiliation_elt, entity_jid),
-                errback=lambda e: dialog.notification.show(
-                    f"can't remove affiliation: {e}", "error")
-            )
-        elif self.invitation_type == 'pubsub':
-            service = self.invitation_data["service"]
-            node = self.invitation_data["node"]
-            bridge.ps_node_affiliations_set(
-                service,
-                node,
-                {entity_jid: "none"},
-                callback=lambda: self._on_affiliation_remove_success(
-                    affiliation_elt, entity_jid),
-                errback=lambda e: dialog.notification.show(
-                    f"can't remove affiliation: {e}", "error")
-            )
-        else:
-            dialog.notification.show(
-                f"error: unknown invitation type: {self.invitation_type}",
-                "error"
-            )
-
-    def _on_affiliation_set_success(self, entity_jid, name, affiliation_elt, affiliation):
-        dialog.notification.show(f"permission updated for {name}")
-        self.affiliations[entity_jid] = affiliation
-        new_affiliation_elt = self.affiliation_tpl.get_elt({
-            "entity_jid": entity_jid,
-            "affiliation": affiliation,
-            "identities": cache.identities,
-        })
-        affiliation_elt.replaceWith(new_affiliation_elt)
-        self._add_affiliation_bindings(entity_jid, new_affiliation_elt)
-
-    def _on_affiliation_set_ok(self, entity_jid, name, affiliation_elt, affiliation):
-        if self.invitation_type == 'pubsub':
-            service = self.invitation_data["service"]
-            node = self.invitation_data["node"]
-            bridge.ps_node_affiliations_set(
-                service,
-                node,
-                {entity_jid: affiliation},
-                callback=lambda: self._on_affiliation_set_success(
-                    entity_jid, name, affiliation_elt, affiliation
-                ),
-                errback=lambda e: dialog.notification.show(
-                    f"can't set affiliation: {e}", "error")
-            )
-        else:
-            dialog.notification.show(
-                f"error: unknown invitation type: {self.invitation_type}",
-                "error"
-            )
-
-    def _on_affiliation_set_cancel(self, evt, notif_elt):
-        notif_elt.remove()
-        self.open()
-
-    def on_affiliation_set(self, entity_jid, name, affiliation_elt, affiliation):
-        if affiliation == "publisher":
-            message = f"Give autorisation to publish to {name}?"
-        elif affiliation == "member":
-            message = f"Remove autorisation to publish from {name}?"
-        else:
-            dialog.notification.show(f"unmanaged affiliation: {affiliation}", "error")
-            return
-        dialog.Confirm(message).show(
-            ok_cb=lambda evt, notif_elt:
-                self._on_affiliation_set_ok(
-                    entity_jid, name, affiliation_elt, affiliation
-                ),
-            cancel_cb=self._on_affiliation_set_cancel
-        )
-        self.close()
--- a/libervia/pages/_browser/loading.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,7 +0,0 @@
-"""manage common dialogs"""
-
-from browser import document
-
-def remove_loading_screen():
-    print("removing loading screen")
-    document['loading_screen'].remove()
--- a/libervia/pages/_browser/slideshow.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,262 +0,0 @@
-from browser import document, window, html, timer, DOMNode
-from js_modules.swiper import Swiper
-from template import Template
-
-
-class SlideShow:
-
-    def __init__(self):
-        self.swiper = None
-        slideshow_tpl = Template('photo/slideshow.html')
-        self.slideshow_elt = slideshow_tpl.get_elt()
-        self.comments_count_elt = self.slideshow_elt.select_one('.comments__count')
-        self.wrapper = self.slideshow_elt.select_one(".swiper-wrapper")
-        self.hidden_elts = {}
-        self.control_hidden = False
-        self.click_timer = None
-        self._class_to_remove = set()
-        self._exit_callback = None
-
-    @property
-    def current_slide(self):
-        if self.swiper is None:
-            return None
-        try:
-            return DOMNode(self.swiper.slides[self.swiper.realIndex])
-        # getting missing item in JS arrays returns KeyError
-        except KeyError:
-            return None
-
-    @property
-    def current_item(self):
-        """item attached to the current slide, if any"""
-        current = self.current_slide
-        if current is None:
-            return None
-        try:
-            return current._item
-        except AttributeError:
-            return None
-
-    @property
-    def current_options(self):
-        """options attached to the current slide, if any"""
-        current = self.current_slide
-        if current is None:
-            return None
-        try:
-            return current._options
-        except AttributeError:
-            return None
-
-    @property
-    def index(self):
-        if self.swiper is None:
-            return None
-        return self.swiper.realIndex
-
-    @index.setter
-    def index(self, idx):
-        if self.swiper is not None:
-            self.swiper.slideTo(idx, 0)
-
-    def attach(self):
-        # we hide other elts to avoid scrolling issues
-        for elt in document.body.children:
-            try:
-                self.hidden_elts[elt] = elt.style.display
-            except AttributeError:
-                pass
-                # FIXME: this is a workaround needed because Brython's children method
-                #   is returning all nodes,
-                #   cf. https://github.com/brython-dev/brython/issues/1657
-                #   to be removed when Brython is fixed.
-            else:
-                elt.style.display = "none"
-        document.body <= self.slideshow_elt
-        self.swiper = Swiper.new(
-            ".swiper-container",
-            {
-                # default 0 value results in lot of accidental swipes, notably when media
-                # player is used
-                "threshold": 10,
-                "pagination": {
-                    "el": ".swiper-pagination",
-                },
-                "navigation": {
-                    "nextEl": ".swiper-button-next",
-                    "prevEl": ".swiper-button-prev",
-                },
-                "scrollbar": {
-                    "el": ".swiper-scrollbar",
-                },
-                "grabCursor": True,
-                "keyboard": {
-                    "enabled": True,
-                    "onlyInViewport": False,
-                },
-                "mousewheel": True,
-                "zoom": {
-                    "maxRatio": 15,
-                    "toggle": False,
-                },
-            }
-        )
-        window.addEventListener("keydown", self.on_key_down, True)
-        self.slideshow_elt.select_one(".click_to_close").bind("click", self.on_close)
-        self.slideshow_elt.select_one(".click_to_comment").bind("click", self.on_comment)
-
-        # we don't use swiper.on for "click" and "dblclick" (or "doubleTap" in swiper
-        # terms) because it breaks event propagation management, which cause trouble with
-        # media player
-        self.slideshow_elt.bind("click", self.on_click)
-        self.slideshow_elt.bind("dblclick", self.on_dblclick)
-        self.swiper.on("slideChange", self.on_slide_change)
-        self.on_slide_change(self.swiper)
-        self.fullscreen(True)
-
-    def add_slide(self, elt, item_data=None, options=None):
-        slide_elt = html.DIV(Class="swiper-slide")
-        zoom_cont_elt = html.DIV(Class="swiper-zoom-container")
-        slide_elt <= zoom_cont_elt
-        zoom_cont_elt <= elt
-        slide_elt._item = item_data
-        if options is not None:
-            slide_elt._options = options
-        self.swiper.appendSlide([slide_elt])
-        self.swiper.update()
-
-    def quit(self):
-        # we unhide
-        for elt, display in self.hidden_elts.items():
-            elt.style.display = display
-        self.hidden_elts.clear()
-        self.slideshow_elt.remove()
-        self.slideshow_elt = None
-        self.swiper.destroy(True, True)
-        self.swiper = None
-
-    def fullscreen(self, active=None):
-        """Activate/desactivate fullscreen
-
-        @param acvite: can be:
-            - True to activate
-            - False to desactivate
-            - Auto to switch fullscreen mode
-        """
-        try:
-            fullscreen_elt = document.fullscreenElement
-            request_fullscreen = self.slideshow_elt.requestFullscreen
-        except AttributeError:
-            print("fullscreen is not available on this browser")
-        else:
-            if active is None:
-                active = fullscreen_elt == None
-            if active:
-                request_fullscreen()
-            else:
-                try:
-                    document.exitFullscreen()
-                except AttributeError:
-                    print("exitFullscreen not available on this browser")
-
-    def on_key_down(self, evt):
-        if evt.key == 'Escape':
-            self.quit()
-        else:
-            return
-        evt.preventDefault()
-
-    def on_slide_change(self, swiper):
-        if self._exit_callback is not None:
-            self._exit_callback()
-            self._exit_callback = None
-        item = self.current_item
-        if item is not None:
-            comments_count = item.get('comments_count')
-            self.comments_count_elt.text = comments_count or ''
-
-        for cls in self._class_to_remove:
-            self.slideshow_elt.classList.remove(cls)
-
-        self._class_to_remove.clear()
-
-        options = self.current_options
-        if options is not None:
-            for flag in options.get('flags', []):
-                cls = f"flag_{flag.lower()}"
-                self.slideshow_elt.classList.add(cls)
-                self._class_to_remove.add(cls)
-            self._exit_callback = options.get("exit_callback", None)
-
-    def toggle_hide_controls(self, evt):
-        self.click_timer = None
-        # we don't want to hide controls when a control is clicked
-        # so we check all ancestors if we are not in a control
-        current = evt.target
-        while current and current != self.slideshow_elt:
-            print(f"current: {current}")
-            if 'slideshow_control' in current.classList:
-                return
-            current = current.parent
-        for elt in self.slideshow_elt.select('.slideshow_control'):
-            elt.style.display = '' if self.control_hidden else 'none'
-        self.control_hidden = not self.control_hidden
-
-    def on_click(self, evt):
-        evt.stopPropagation()
-        evt.preventDefault()
-        # we use a timer so double tap can cancel the click
-        # this avoid double tap side effect
-        if self.click_timer is None:
-            self.click_timer = timer.set_timeout(
-                lambda: self.toggle_hide_controls(evt), 300)
-
-    def on_dblclick(self, evt):
-        evt.stopPropagation()
-        evt.preventDefault()
-        if self.click_timer is not None:
-            timer.clear_timeout(self.click_timer)
-            self.click_timer = None
-        if self.swiper.zoom.scale != 1:
-            self.swiper.zoom.toggle()
-        else:
-            # "in" is reserved in Python, so we call it using dict syntax
-            self.swiper.zoom["in"]()
-
-    def on_close(self, evt):
-        evt.stopPropagation()
-        evt.preventDefault()
-        self.quit()
-
-    def on_comment_close(self, evt):
-        evt.stopPropagation()
-        side_panel = self.comments_panel_elt.select_one('.comments_side_panel')
-        side_panel.classList.remove('open')
-        side_panel.bind("transitionend", lambda evt: self.comments_panel_elt.remove())
-
-    def on_comments_panel_click(self, evt):
-        # we stop stop propagation to avoid the closing of the panel
-        evt.stopPropagation()
-
-    def on_comment(self, evt):
-        item = self.current_item
-        if item is None:
-            return
-        comments_panel_tpl = Template('blog/comments_panel.html')
-        try:
-            comments = item['comments']['items']
-        except KeyError:
-            comments = []
-        self.comments_panel_elt = comments_panel_tpl.get_elt({
-            "comments": comments,
-            "comments_service": item['comments_service'],
-            "comments_node": item['comments_node'],
-
-        })
-        self.slideshow_elt <= self.comments_panel_elt
-        side_panel = self.comments_panel_elt.select_one('.comments_side_panel')
-        timer.set_timeout(lambda: side_panel.classList.add("open"), 0)
-        for close_elt in self.comments_panel_elt.select('.click_to_close'):
-            close_elt.bind("click", self.on_comment_close)
-        side_panel.bind("click", self.on_comments_panel_click)
--- a/libervia/pages/_browser/template.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,235 +0,0 @@
-"""Integrate templating system using nunjucks"""
-
-from js_modules.nunjucks import nunjucks
-from browser import window, document
-import javascript
-
-
-safe = nunjucks.runtime.SafeString.new
-env = nunjucks.configure(
-    window.templates_root_url,
-    {
-        'autoescape': True,
-        'trimBlocks': True,
-        'lstripBlocks': True,
-        'web': {'use_cache': True},
-    })
-
-nunjucks.installJinjaCompat()
-env.addGlobal("profile", window.profile)
-env.addGlobal("csrf_token", window.csrf_token)
-# FIXME: integrate gettext or equivalent here
-env.addGlobal("_", lambda txt: txt)
-
-
-class Indexer:
-    """Index global to a page"""
-
-    def __init__(self):
-        self._indexes = {}
-
-    def next(self, value):
-        if value not in self._indexes:
-            self._indexes[value] = 0
-            return 0
-        self._indexes[value] += 1
-        return self._indexes[value]
-
-    def current(self, value):
-        return self._indexes.get(value)
-
-
-gidx = Indexer()
-# suffix use to avoid collision with IDs generated in static page
-SCRIPT_SUFF = "__script__"
-
-def escape_html(txt):
-    return (
-        txt
-        .replace('&', '&amp;')
-        .replace('<', '&lt;')
-        .replace('>', '&gt;')
-        .replace('"', '&quot;')
-    )
-
-
-def get_args(n_args, *sig_args, **sig_kwargs):
-    """Retrieve function args when they are transmitted using nunjucks convention
-
-    cf. https://mozilla.github.io/nunjucks/templating.html#keyword-arguments
-    @param n_args: argument from nunjucks call
-    @param sig_args: expected positional arguments
-    @param sig_kwargs: expected keyword arguments
-    @return: all expected arguments, with default value if not specified in nunjucks
-    """
-    # nunjucks set kwargs in last argument
-    given_args = list(n_args)
-    try:
-        given_kwargs = given_args.pop().to_dict()
-    except (AttributeError, IndexError):
-        # we don't have a dict as last argument
-        # that happens when there is no keyword argument
-        given_args = list(n_args)
-        given_kwargs = {}
-    ret = given_args[:len(sig_args)]
-    # we check if we have remaining positional arguments
-    # in which case they may be specified in keyword arguments
-    for name in sig_args[len(given_args):]:
-        try:
-            value = given_kwargs.pop(name)
-        except KeyError:
-            raise ValueError(f"missing positional arguments {name!r}")
-        ret.append(value)
-
-    extra_pos_args = given_args[len(sig_args):]
-    # and now the keyword arguments
-    for name, default in sig_kwargs.items():
-        if extra_pos_args:
-            # kw args has been specified with a positional argument
-            ret.append(extra_pos_args.pop(0))
-            continue
-        value = given_kwargs.get(name, default)
-        ret.append(value)
-
-    return ret
-
-
-def _next_gidx(value):
-    """Use next current global index as suffix"""
-    next_ = gidx.next(value)
-    return f"{value}{SCRIPT_SUFF}" if next_ == 0 else f"{value}_{SCRIPT_SUFF}{next_}"
-
-env.addFilter("next_gidx", _next_gidx)
-
-
-def _cur_gidx(value):
-    """Use current current global index as suffix"""
-    current = gidx.current(value)
-    return f"{value}{SCRIPT_SUFF}" if not current else f"{value}_{SCRIPT_SUFF}{current}"
-
-env.addFilter("cur_gidx", _cur_gidx)
-
-
-def _xmlattr(d, autospace=True):
-    if not d:
-        return
-    d = d.to_dict()
-    ret = [''] if autospace else []
-    for key, value in d.items():
-        if value is not None:
-            ret.append(f'{escape_html(key)}="{escape_html(str(value))}"')
-
-    return safe(' '.join(ret))
-
-env.addFilter("xmlattr", _xmlattr)
-
-
-def _tojson(value):
-    return safe(escape_html(window.JSON.stringify(value)))
-
-env.addFilter("tojson", _tojson)
-
-
-def _icon_use(name, cls=""):
-    kwargs = cls.to_dict()
-    cls = kwargs.get('cls')
-    return safe(
-        '<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
-        'viewBox="0 0 100 100">\n'
-        '    <use href="#{name}"/>'
-        '</svg>\n'.format(name=name, cls=(" " + cls) if cls else "")
-    )
-
-env.addGlobal("icon", _icon_use)
-
-
-def _date_fmt(
-    timestamp, *args
-):
-    """Date formatting
-
-    cf. sat.tools.common.date_utils for arguments details
-    """
-    fmt, date_only, auto_limit, auto_old_fmt, auto_new_fmt = get_args(
-        args, fmt="short", date_only=False, auto_limit=7, auto_old_fmt="short",
-        auto_new_fmt="relative",
-    )
-    from js_modules.moment import moment
-    date = moment.unix(timestamp)
-
-    if fmt == "auto_day":
-        fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm"
-    if fmt == "auto":
-        limit = moment().startOf('day').subtract(auto_limit, 'days')
-        m_fmt = auto_old_fmt if date < limit else auto_new_fmt
-
-    if fmt == "short":
-        m_fmt = "DD/MM/YY" if date_only else "DD/MM/YY HH:mm"
-    elif fmt == "medium":
-        m_fmt = "ll" if date_only else "lll"
-    elif fmt == "long":
-        m_fmt = "LL" if date_only else "LLL"
-    elif fmt == "full":
-        m_fmt = "dddd, LL" if date_only else "LLLL"
-    elif fmt == "relative":
-        return date.fromNow()
-    elif fmt == "iso":
-        if date_only:
-            m_fmt == "YYYY-MM-DD"
-        else:
-            return date.toISOString()
-    else:
-        raise NotImplementedError("free format is not implemented yet")
-
-    return date.format(m_fmt)
-
-env.addFilter("date_fmt", _date_fmt)
-
-
-class I18nExtension:
-    """Extension to handle the {% trans %}{% endtrans %} statement"""
-    # FIXME: for now there is no translation, this extension only returns the string
-    #        unmodified
-    tags = ['trans']
-
-    def parse(self, parser, nodes, lexer):
-        tok = parser.nextToken()
-        args = parser.parseSignature(None, True)
-        parser.advanceAfterBlockEnd(tok.value)
-        body = parser.parseUntilBlocks('endtrans')
-        parser.advanceAfterBlockEnd()
-        return nodes.CallExtension.new(self._js_ext, 'run', args, [body])
-
-    def run(self, context, *args):
-        body = args[-1]
-        return body()
-
-    @classmethod
-    def install(cls, env):
-        ext = cls()
-        ext_dict = {
-            "tags": ext.tags,
-            "parse": ext.parse,
-            "run": ext.run
-        }
-        ext._js_ext = javascript.pyobj2jsobj(ext_dict)
-        env.addExtension(cls.__name__, ext._js_ext)
-
-I18nExtension.install(env)
-
-
-class Template:
-
-    def __init__(self, tpl_name):
-        self._tpl = env.getTemplate(tpl_name, True)
-
-    def render(self, context):
-        return self._tpl.render(context)
-
-    def get_elt(self, context=None):
-        if context is None:
-            context = {}
-        raw_html = self.render(context)
-        template_elt = document.createElement('template')
-        template_elt.innerHTML = raw_html
-        return template_elt.content.firstElementChild
--- a/libervia/pages/_browser/tmp_aio.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,26 +0,0 @@
-from browser import window
-
-"""Q&D module to do ajax requests with data types currently unsupported by Brython"""
-# FIXME: remove this module when official aio module allows to work with blobs
-
-window.eval("""
-var _tmp_ajax = function(method, url, format, data){
-    return new Promise(function(resolve, reject){
-        var xhr = new XMLHttpRequest()
-        xhr.open(method, url, true)
-        xhr.responseType = format
-        xhr.onreadystatechange = function(){
-            if(this.readyState == 4){
-                resolve(this)
-            }
-        }
-        if(data){
-            xhr.send(data)
-        }else{
-            xhr.send()
-        }
-    })
-}
-""")
-
-ajax = window._tmp_ajax
--- a/libervia/pages/app/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-#!/usr/bin/env python3
-
-
-name = "app"
-template = "app/app.html"
--- a/libervia/pages/blog/edit/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-import editor
-
-
-editor.set_form_autosave("blog_post_edit")
-editor.BlogEditor()
-editor.TagsEditor("input[name='tags']")
--- a/libervia/pages/blog/edit/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,36 +0,0 @@
-#!/usr/bin/env python3
-
-from libervia.server.constants import Const as C
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-name = "blog_edit"
-access = C.PAGES_ACCESS_PROFILE
-template = "blog/publish.html"
-
-
-async def on_data_post(self, request):
-    profile = self.get_profile(request)
-    if profile is None:
-        self.page_error(request, C.HTTP_FORBIDDEN)
-    request_data = self.get_r_data(request)
-    title, tags, body = self.get_posted_data(request, ('title', 'tags', 'body'))
-    mb_data = {"content_rich": body, "allow_comments": True}
-    title = title.strip()
-    if title:
-        mb_data["title_rich"] = title
-    tags = [t.strip() for t in tags.split(',') if t.strip()]
-    if tags:
-        mb_data["tags"] = tags
-
-    await self.host.bridge_call(
-        'mb_send',
-        "",
-        "",
-        data_format.serialise(mb_data),
-        profile
-    )
-
-    request_data["post_redirect_page"] = self.get_page_by_name("blog")
--- a/libervia/pages/blog/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,72 +0,0 @@
-#!/usr/bin/env python3
-
-from sat.core.i18n import _
-from libervia.server.constants import Const as C
-from twisted.words.protocols.jabber import jid
-from twisted.internet import defer
-from libervia.server import session_iface
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-name = "blog"
-access = C.PAGES_ACCESS_PUBLIC
-template = "blog/discover.html"
-
-
-async def prepare_render(self, request):
-    profile = self.get_profile(request)
-    template_data = request.template_data
-    if profile is not None:
-        __, entities_own, entities_roster = await self.host.bridge_call(
-            "disco_find_by_features",
-            [],
-            [("pubsub", "pep")],
-            True,
-            False,
-            True,
-            True,
-            True,
-            profile,
-        )
-        entities = template_data["disco_entities"] = (
-            list(entities_own.keys()) + list(entities_roster.keys())
-        )
-        entities_url = template_data["entities_url"] = {}
-        identities = self.host.get_session_data(
-            request, session_iface.IWebSession
-        ).identities
-        d_list = {}
-        for entity_jid_s in entities:
-            entities_url[entity_jid_s] = self.get_page_by_name("blog_view").get_url(
-                entity_jid_s
-            )
-            if entity_jid_s not in identities:
-                d_list[entity_jid_s] = self.host.bridge_call(
-                        "identity_get",
-                        entity_jid_s,
-                        [],
-                        True,
-                        profile)
-        identities_data = await defer.DeferredList(d_list.values())
-        entities_idx = list(d_list.keys())
-        for idx, (success, identity_raw) in enumerate(identities_data):
-            entity_jid_s = entities_idx[idx]
-            if not success:
-                log.warning(_("Can't retrieve identity of {entity}")
-                    .format(entity=entity_jid_s))
-            else:
-                identities[entity_jid_s] = data_format.deserialise(identity_raw)
-
-        template_data["url_blog_edit"] = self.get_sub_page_url(request, "blog_edit")
-
-
-def on_data_post(self, request):
-    jid_str = self.get_posted_data(request, "jid")
-    try:
-        jid_ = jid.JID(jid_str)
-    except RuntimeError:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    url = self.get_page_by_name("blog_view").get_url(jid_.full())
-    self.http_redirect(request, url)
--- a/libervia/pages/blog/view/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,6 +0,0 @@
-#!/usr/bin/env python3
-
-import alt_media_player
-
-
-alt_media_player.install_if_needed()
--- a/libervia/pages/blog/view/atom.xml/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,38 +0,0 @@
-#!/usr/bin/env python3
-
-from libervia.server.constants import Const as C
-from sat.tools.common import uri
-import time
-
-name = "blog_feed_atom"
-access = C.PAGES_ACCESS_PUBLIC
-template = "blog/atom.xml"
-
-
-async def prepare_render(self, request):
-    request.setHeader("Content-Type", "application/atom+xml; charset=utf-8")
-    data = self.get_r_data(request)
-    service, node = data["service"], data.get("node")
-    self.check_cache(
-        request, C.CACHE_PUBSUB, service=service, node=node, short="microblog"
-    )
-    data["show_comments"] = False
-    template_data = request.template_data
-    blog_page = self.get_page_by_name("blog_view")
-    await blog_page.prepare_render(self, request)
-    items = data["blog_items"]['items']
-
-    template_data["request_uri"] = self.host.get_ext_base_url(
-        request, request.path.decode("utf-8")
-    )
-    template_data["xmpp_uri"] = uri.build_xmpp_uri(
-        "pubsub", subtype="microblog", path=service.full(), node=node
-    )
-    blog_view = self.get_page_by_name("blog_view")
-    template_data["http_uri"] = self.host.get_ext_base_url(
-        request, blog_view.get_url(service.full(), node)
-    )
-    if items:
-        template_data["updated"] = items[0]['updated']
-    else:
-        template_data["updated"] = time.time()
--- a/libervia/pages/blog/view/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,381 +0,0 @@
-#!/usr/bin/env python3
-
-import html
-from typing import Any, Dict, Optional
-
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools.common import uri
-from sat.tools.common import data_format
-from sat.tools.common import regex
-from sat.tools.common.template import safe
-from twisted.web import server
-from twisted.words.protocols.jabber import jid
-
-from libervia.server import utils
-from libervia.server.constants import Const as C
-from libervia.server.utils import SubPage
-
-log = getLogger(__name__)
-
-"""generic blog (with service/node provided)"""
-name = 'blog_view'
-template = "blog/articles.html"
-uri_handlers = {('pubsub', 'microblog'): 'microblog_uri'}
-
-URL_LIMIT_MARK = 90  # if canonical URL is longer than that, text will not be appended
-
-
-def microblog_uri(self, uri_data):
-    args = [uri_data['path'], uri_data['node']]
-    if 'item' in uri_data:
-        args.extend(['id', uri_data['item']])
-    return self.get_url(*args)
-
-def parse_url(self, request):
-    """URL is /[service]/[node]/[filter_keyword]/[item]|[other]
-
-    if [node] is '@', default namespace is used
-    if a value is unset, default one will be used
-    keyword can be one of:
-        id: next value is a item id
-        tag: next value is a blog tag
-    """
-    data = self.get_r_data(request)
-
-    try:
-        service = self.next_path(request)
-    except IndexError:
-        data['service'] = ''
-    else:
-        try:
-            data["service"] = jid.JID(service)
-        except Exception:
-            log.warning(_("bad service entered: {}").format(service))
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    try:
-        node = self.next_path(request)
-    except IndexError:
-        node = '@'
-    data['node'] = '' if node == '@' else node
-
-    try:
-        filter_kw = data['filter_keyword'] = self.next_path(request)
-    except IndexError:
-        filter_kw = '@'
-    else:
-        if filter_kw == '@':
-            # No filter, this is used when a subpage is needed, notably Atom feed
-            pass
-        elif filter_kw == 'id':
-            try:
-                data['item'] = self.next_path(request)
-            except IndexError:
-                self.page_error(request, C.HTTP_BAD_REQUEST)
-            # we get one more argument in case text has been added to have a nice URL
-            try:
-                self.next_path(request)
-            except IndexError:
-                pass
-        elif filter_kw == 'tag':
-            try:
-                data['tag'] = self.next_path(request)
-            except IndexError:
-                self.page_error(request, C.HTTP_BAD_REQUEST)
-        else:
-            # invalid filter keyword
-            log.warning(_("invalid filter keyword: {filter_kw}").format(
-                filter_kw=filter_kw))
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    # if URL is parsed here, we'll have atom.xml available and we need to
-    # add the link to the page
-    atom_url = self.get_url_by_path(
-        SubPage('blog_view'),
-        service,
-        node,
-        filter_kw,
-        SubPage('blog_feed_atom'),
-    )
-    request.template_data['atom_url'] = atom_url
-    request.template_data.setdefault('links', []).append({
-        "href": atom_url,
-        "type": "application/atom+xml",
-        "rel": "alternate",
-        "title": "{service}'s blog".format(service=service)})
-
-
-def add_breadcrumb(self, request, breadcrumbs):
-    data = self.get_r_data(request)
-    breadcrumbs.append({
-        "label": D_("Feed"),
-        "url": self.get_url(data["service"].full(), data.get("node", "@"))
-    })
-    if "item" in data:
-        breadcrumbs.append({
-            "label": D_("Post"),
-        })
-
-
-async def append_comments(
-    self,
-    request: server.Request,
-    blog_items: dict,
-    profile: str,
-    _seen: Optional[set] = None
-) -> None:
-    """Recursively download and append comments of items
-
-    @param blog_items: items data
-    @param profile: Libervia profile
-    @param _seen: used to avoid infinite recursion. For internal use only
-    """
-    if _seen is None:
-        _seen = set()
-    await self.fill_missing_identities(
-        request, [i['author_jid'] for i in blog_items['items']])
-    extra: Dict[str, Any] = {C.KEY_ORDER_BY: C.ORDER_BY_CREATION}
-    if not self.use_cache(request):
-        extra[C.KEY_USE_CACHE] = False
-    for blog_item in blog_items['items']:
-        for comment_data in blog_item['comments']:
-            service = comment_data['service']
-            node = comment_data['node']
-            service_node = (service, node)
-            if service_node in _seen:
-                log.warning(
-                    f"Items from {node!r} at {service} have already been retrieved, "
-                    "there is a recursion at this service"
-                )
-                comment_data["items"] = []
-                continue
-            else:
-                _seen.add(service_node)
-            try:
-                comments_data = await self.host.bridge_call('mb_get',
-                                      service,
-                                      node,
-                                      C.NO_LIMIT,
-                                      [],
-                                      data_format.serialise(
-                                          extra
-                                      ),
-                                      profile)
-            except Exception as e:
-                log.warning(
-                    _("Can't get comments at {node} (service: {service}): {msg}").format(
-                        service=service,
-                        node=node,
-                        msg=e))
-                comment_data['items'] = []
-                continue
-
-            comments = data_format.deserialise(comments_data)
-            if comments is None:
-                log.error(f"Comments should not be None: {comment_data}")
-                comment_data["items"] = []
-                continue
-            comment_data['items'] = comments['items']
-            await append_comments(self, request, comments, profile, _seen=_seen)
-
-async def get_blog_items(
-    self,
-    request: server.Request,
-    service: jid.JID,
-    node: str,
-    item_id,
-    extra: Dict[str, Any],
-    profile: str
-) -> dict:
-    try:
-        if item_id:
-            items_id = [item_id]
-        else:
-            items_id = []
-        if not self.use_cache(request):
-            extra[C.KEY_USE_CACHE] = False
-        blog_data = await self.host.bridge_call('mb_get',
-                              service.userhost(),
-                              node,
-                              C.NO_LIMIT,
-                              items_id,
-                              data_format.serialise(extra),
-                              profile)
-    except Exception as e:
-        # FIXME: need a better way to test errors in bridge errback
-        if "forbidden" in str(e):
-            self.page_error(request, 401)
-        else:
-            log.warning(_("can't retrieve blog for [{service}]: {msg}".format(
-                service = service.userhost(), msg=e)))
-            blog_data = {"items": []}
-    else:
-        blog_data = data_format.deserialise(blog_data)
-
-    return blog_data
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    page_max = data.get("page_max", 10)
-    # if the comments are not explicitly hidden, we show them
-    service, node, item_id, show_comments = (
-        data.get('service', ''),
-        data.get('node', ''),
-        data.get('item'),
-        data.get('show_comments', True)
-    )
-    profile = self.get_profile(request)
-    if profile is None:
-        profile = C.SERVICE_PROFILE
-        profile_connected = False
-    else:
-        profile_connected = True
-
-    ## pagination/filtering parameters
-    if item_id:
-        extra = {}
-    else:
-        extra = self.get_pubsub_extra(request, page_max=page_max)
-        tag = data.get('tag')
-        if tag:
-            extra[f'mam_filter_{C.MAM_FILTER_CATEGORY}'] = tag
-        self.handle_search(request, extra)
-
-    ## main data ##
-    # we get data from backend/XMPP here
-    blog_items = await get_blog_items(self, request, service, node, item_id, extra, profile)
-
-    ## navigation ##
-    # no let's fill service, node and pagination URLs
-    if 'service' not in template_data:
-        template_data['service'] = service
-    if 'node' not in template_data:
-        template_data['node'] = node
-    target_profile = template_data.get('target_profile')
-
-    if blog_items:
-        if item_id:
-            template_data["previous_page_url"] = self.get_url(
-                service.full(),
-                node,
-                before=item_id,
-                page_max=1
-            )
-            template_data["next_page_url"] = self.get_url(
-                service.full(),
-                node,
-                after=item_id,
-                page_max=1
-            )
-            blog_items["rsm"] = {
-                "last": item_id,
-                "first": item_id,
-            }
-            blog_items["complete"] = False
-        else:
-            self.set_pagination(request, blog_items)
-    else:
-        if item_id:
-            # if item id has been specified in URL and it's not found,
-            # we must return an error
-            self.page_error(request, C.HTTP_NOT_FOUND)
-
-    ## identities ##
-    # identities are used to show nice nickname or avatars
-    await self.fill_missing_identities(request, [i['author_jid'] for i in blog_items['items']])
-
-    ## Comments ##
-    # if comments are requested, we need to take them
-    if show_comments:
-        await append_comments(self, request, blog_items, profile)
-
-    ## URLs ##
-    # We will fill items_http_uri and tags_http_uri in template_data with suitable urls
-    # if we know the profile, we use it instead of service + blog (nicer url)
-    if target_profile is None:
-        blog_base_url_item = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'id')
-        blog_base_url_tag = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'tag')
-    else:
-        blog_base_url_item = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['id'])])
-        blog_base_url_tag = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['tag'])])
-        # we also set the background image if specified by user
-        bg_img = await self.host.bridge_call('param_get_a_async', 'Background', 'Blog page', 'value', -1, template_data['target_profile'])
-        if bg_img:
-            template_data['dynamic_style'] = safe("""
-                :root {
-                    --bg-img: url("%s");
-                }
-                """ % html.escape(bg_img, True))
-
-    template_data['blog_items'] = data['blog_items'] = blog_items
-    if request.args.get(b'reverse') == ['1']:
-        template_data['blog_items'].items.reverse()
-    template_data['items_http_uri'] = items_http_uri = {}
-    template_data['tags_http_uri'] = tags_http_uri = {}
-
-
-    for item in blog_items['items']:
-        blog_canonical_url = '/'.join([blog_base_url_item, utils.quote(item['id'])])
-        if len(blog_canonical_url) > URL_LIMIT_MARK:
-            blog_url = blog_canonical_url
-        elif '-' not in item['id']:
-            # we add text from title or body at the end of URL
-            # to make it more human readable
-            # we do it only if there is no "-", as a "-" probably means that
-            # item's id is already user friendly.
-            # TODO: to be removed,  this is only kept for a transition period until
-            #   user friendly item IDs are more common.
-            text = regex.url_friendly_text(item.get('title', item['content']))
-            if text:
-                blog_url = blog_canonical_url + '/' + text
-            else:
-                blog_url = blog_canonical_url
-        else:
-            blog_url = blog_canonical_url
-
-        items_http_uri[item['id']] = self.host.get_ext_base_url(request, blog_url)
-        for tag in item['tags']:
-            if tag not in tags_http_uri:
-                tag_url = '/'.join([blog_base_url_tag, utils.quote(tag)])
-                tags_http_uri[tag] = self.host.get_ext_base_url(request, tag_url)
-
-    # if True, page should display a comment box
-    template_data['allow_commenting'] = data.get('allow_commenting', profile_connected)
-
-    # last but not least, we add a xmpp: link to the node
-    uri_args = {'path': service.full()}
-    if node:
-        uri_args['node'] = node
-    if item_id:
-        uri_args['item'] = item_id
-    template_data['xmpp_uri'] = uri.build_xmpp_uri(
-        'pubsub', subtype='microblog', **uri_args
-    )
-
-
-async def on_data_post(self, request):
-    profile = self.get_profile(request)
-    if profile is None:
-        self.page_error(request, C.HTTP_FORBIDDEN)
-    type_ = self.get_posted_data(request, 'type')
-    if type_ == 'comment':
-        service, node, body = self.get_posted_data(request, ('service', 'node', 'body'))
-
-        if not body:
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-        comment_data = {"content_rich": body}
-        try:
-            await self.host.bridge_call('mb_send',
-                                       service,
-                                       node,
-                                       data_format.serialise(comment_data),
-                                       profile)
-        except Exception as e:
-            if "forbidden" in str(e):
-                self.page_error(request, 401)
-            else:
-                raise e
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/calendar/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,25 +0,0 @@
-from browser import document, window
-from browser.timer import set_interval
-
-calendar_start = window.calendar_start
-
-
-def update_current_time_line():
-    now = window.Date.new()
-
-    # Calculate the position of the current-time-line
-    now_ts = now.getTime() / 1000
-    minutes_passed = (now_ts - calendar_start) / 60
-
-    new_top = minutes_passed + 15
-
-    # Update the current-time-line position and make it visible
-    current_time_line = document["current-time-line"]
-    current_time_line.style.top = f"{new_top}px"
-    current_time_line.hidden = False
-
-# Initial update
-update_current_time_line()
-
-# Update the current-time-line every minute
-set_interval(update_current_time_line, 60 * 1000)
--- a/libervia/pages/calendar/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,52 +0,0 @@
-#!/usr/bin/env python3
-
-
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from twisted.internet import defer
-import datetime
-import time
-from dateutil import tz
-
-from libervia.server.constants import Const as C
-
-log = getLogger(__name__)
-
-
-name = "calendar"
-access = C.PAGES_ACCESS_PROFILE
-template = "calendar/daily.html"
-
-
-async def prepare_render(self, request):
-    profile = self.get_profile(request)
-    template_data = request.template_data
-    # template_data["url_event_new"] = self.get_sub_page_url(request, "event_new")
-    if profile is not None:
-        try:
-            events = data_format.deserialise(
-                await self.host.bridge_call("events_get", "", "", [], "", profile),
-                type_check=list
-            )
-        except Exception as e:
-            log.warning(_("Can't get events list for {profile}: {reason}").format(
-                profile=profile, reason=e))
-        else:
-            template_data["events"] = events
-
-    tz_name = template_data["tz_name"] = time.tzname[0]
-    local_tz = tz.tzlocal()
-    today_local = datetime.datetime.now(local_tz).date()
-    calendar_start = template_data["calendar_start"] = datetime.datetime.combine(
-        today_local, datetime.time.min, tzinfo=local_tz
-    ).timestamp()
-    calendar_end = template_data["calendar_end"] = datetime.datetime.combine(
-        today_local, datetime.time.max, tzinfo=local_tz
-    ).timestamp()
-    self.expose_to_scripts(
-        request,
-        calendar_start=calendar_start,
-        calendar_end=calendar_end,
-        tz_name=tz_name,
-    )
--- a/libervia/pages/calls/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,513 +0,0 @@
-import json
-import re
-
-from bridge import AsyncBridge as Bridge
-from browser import aio, console as log, document, timer, window
-import errors
-import loading
-
-log.warning = log.warn
-profile = window.profile or ""
-bridge = Bridge()
-GATHER_TIMEOUT = 10000
-
-
-class WebRTCCall:
-
-    def __init__(self):
-        self.reset_instance()
-
-    def reset_instance(self):
-        """Inits or resets the instance variables to their default state."""
-        self._peer_connection = None
-        self._media_types = None
-        self.sid = None
-        self.local_candidates = None
-        self.remote_stream = None
-        self.candidates_buffer = {
-            "audio": {"candidates": []},
-            "video": {"candidates": []},
-        }
-        self.media_candidates = {}
-        self.candidates_gathered = aio.Future()
-
-    @property
-    def media_types(self):
-        if self._media_types is None:
-            raise Exception("self._media_types should not be None!")
-        return self._media_types
-
-    def get_sdp_mline_index(self, media_type):
-        """Gets the sdpMLineIndex for a given media type.
-
-        @param media_type: The type of the media.
-        """
-        for index, m_type in self.media_types.items():
-            if m_type == media_type:
-                return index
-        raise ValueError(f"Media type '{media_type}' not found")
-
-    def extract_pwd_ufrag(self, sdp):
-        """Retrieves ICE password and user fragment for SDP offer.
-
-        @param sdp: The Session Description Protocol offer string.
-        """
-        ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp)
-        pwd_line = re.search(r"ice-pwd:(\S+)", sdp)
-
-        if ufrag_line and pwd_line:
-            return ufrag_line.group(1), pwd_line.group(1)
-        else:
-            log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}")
-            raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP")
-
-    def extract_fingerprint_data(self, sdp):
-        """Retrieves fingerprint data from an SDP offer.
-
-        @param sdp: The Session Description Protocol offer string.
-        @return: A dictionary containing the fingerprint data.
-        """
-        fingerprint_line = re.search(r"a=fingerprint:(\S+)\s+(\S+)", sdp)
-        if fingerprint_line:
-            algorithm, fingerprint = fingerprint_line.groups()
-            fingerprint_data = {
-                "hash": algorithm,
-                "fingerprint": fingerprint
-            }
-
-            setup_line = re.search(r"a=setup:(\S+)", sdp)
-            if setup_line:
-                setup = setup_line.group(1)
-                fingerprint_data["setup"] = setup
-
-            return fingerprint_data
-        else:
-            raise ValueError("fingerprint should not be missing")
-
-    def parse_ice_candidate(self, candidate_string):
-        """Parses the ice candidate string.
-
-        @param candidate_string: The ice candidate string to be parsed.
-        """
-        pattern = re.compile(
-            r"candidate:(?P<foundation>\S+) (?P<component_id>\d+) (?P<transport>\S+) "
-            r"(?P<priority>\d+) (?P<address>\S+) (?P<port>\d+) typ "
-            r"(?P<type>\S+)(?: raddr (?P<rel_addr>\S+) rport "
-            r"(?P<rel_port>\d+))?(?: generation (?P<generation>\d+))?"
-        )
-        match = pattern.match(candidate_string)
-        if match:
-            candidate_dict = match.groupdict()
-
-            # Apply the correct types to the dictionary values
-            candidate_dict["component_id"] = int(candidate_dict["component_id"])
-            candidate_dict["priority"] = int(candidate_dict["priority"])
-            candidate_dict["port"] = int(candidate_dict["port"])
-
-            if candidate_dict["rel_port"]:
-                candidate_dict["rel_port"] = int(candidate_dict["rel_port"])
-
-            if candidate_dict["generation"]:
-                candidate_dict["generation"] = candidate_dict["generation"]
-
-            # Remove None values
-            return {k: v for k, v in candidate_dict.items() if v is not None}
-        else:
-            log.warning(f"can't parse candidate: {candidate_string!r}")
-            return None
-
-    def build_ice_candidate(self, parsed_candidate):
-        """Builds ICE candidate
-
-        @param parsed_candidate: Dictionary containing parsed ICE candidate
-        """
-        base_format = (
-            "candidate:{foundation} {component_id} {transport} {priority} "
-            "{address} {port} typ {type}"
-        )
-
-        if ((parsed_candidate.get('rel_addr')
-             and parsed_candidate.get('rel_port'))):
-            base_format += " raddr {rel_addr} rport {rel_port}"
-
-        if parsed_candidate.get('generation'):
-            base_format += " generation {generation}"
-
-        return base_format.format(**parsed_candidate)
-
-    def on_ice_candidate(self, event):
-        """Handles ICE candidate event
-
-        @param event: Event containing the ICE candidate
-        """
-        log.debug(f"on ice candidate {event.candidate=}")
-        if event.candidate and event.candidate.candidate:
-            window.last_event = event
-            parsed_candidate = self.parse_ice_candidate(event.candidate.candidate)
-            if parsed_candidate is None:
-                return
-            try:
-                media_type = self.media_types[event.candidate.sdpMLineIndex]
-            except (TypeError, IndexError):
-                log.error(
-                    f"Can't find media type.\n{event.candidate=}\n{self._media_types=}"
-                )
-                return
-            self.media_candidates.setdefault(media_type, []).append(parsed_candidate)
-            log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}")
-        else:
-            log.debug("All ICE candidates gathered")
-
-    def _set_media_types(self, offer):
-        """Sets media types from offer SDP
-
-        @param offer: RTC session description containing the offer
-        """
-        sdp_lines = offer.sdp.splitlines()
-        media_types = {}
-        mline_index = 0
-
-        for line in sdp_lines:
-            if line.startswith("m="):
-                media_types[mline_index] = line[2:line.find(" ")]
-                mline_index += 1
-
-        self._media_types = media_types
-
-    def on_ice_gathering_state_change(self, event):
-        """Handles ICE gathering state change
-
-        @param event: Event containing the ICE gathering state change
-        """
-        connection = event.target
-        log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}")
-        if connection.iceGatheringState == "complete":
-            log.info("ICE candidates gathering done")
-            self.candidates_gathered.set_result(None)
-
-    async def _create_peer_connection(
-        self,
-    ):
-        """Creates peer connection"""
-        if self._peer_connection is not None:
-            raise Exception("create_peer_connection can't be called twice!")
-
-        external_disco = json.loads(await bridge.external_disco_get(""))
-        ice_servers = []
-
-        for server in external_disco:
-            ice_server = {}
-            if server["type"] == "stun":
-                ice_server["urls"] = f"stun:{server['host']}:{server['port']}"
-            elif server["type"] == "turn":
-                ice_server["urls"] = (
-                    f"turn:{server['host']}:{server['port']}?transport={server['transport']}"
-                )
-                ice_server["username"] = server["username"]
-                ice_server["credential"] = server["password"]
-            ice_servers.append(ice_server)
-
-        rtc_configuration = {"iceServers": ice_servers}
-
-        peer_connection = window.RTCPeerConnection.new(rtc_configuration)
-        peer_connection.addEventListener("track", self.on_track)
-        peer_connection.addEventListener("negotiationneeded", self.on_negotiation_needed)
-        peer_connection.addEventListener("icecandidate", self.on_ice_candidate)
-        peer_connection.addEventListener("icegatheringstatechange", self.on_ice_gathering_state_change)
-
-        self._peer_connection = peer_connection
-        window.pc = self._peer_connection
-
-    async def _get_user_media(
-        self,
-        audio: bool = True,
-        video: bool = True
-    ):
-        """Gets user media
-
-        @param audio: True if an audio flux is required
-        @param video: True if a video flux is required
-        """
-        media_constraints = {'audio': audio, 'video': video}
-        local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints)
-        document["local_video"].srcObject = local_stream
-
-        for track in local_stream.getTracks():
-            self._peer_connection.addTrack(track)
-
-    async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None):
-        """Get ICE candidates and wait to have them all before returning them
-
-        @param is_initiator: Boolean indicating if the user is the initiator of the connection
-        @param remote_candidates: Remote ICE candidates, if any
-        """
-        if self._peer_connection is None:
-            raise Exception("The peer connection must be created before gathering ICE candidates!")
-
-        self.media_candidates.clear()
-        gather_timeout = timer.set_timeout(
-            lambda: self.candidates_gathered.set_exception(
-                errors.TimeoutError("ICE gathering time out")
-            ),
-            GATHER_TIMEOUT
-        )
-
-        if is_initiator:
-            offer = await self._peer_connection.createOffer()
-            self._set_media_types(offer)
-            await self._peer_connection.setLocalDescription(offer)
-        else:
-            answer = await self._peer_connection.createAnswer()
-            self._set_media_types(answer)
-            await self._peer_connection.setLocalDescription(answer)
-
-        if not is_initiator:
-            log.debug(self._peer_connection.localDescription.sdp)
-        await self.candidates_gathered
-        log.debug(self._peer_connection.localDescription.sdp)
-        timer.clear_timeout(gather_timeout)
-        ufrag, pwd = self.extract_pwd_ufrag(self._peer_connection.localDescription.sdp)
-        return {
-            "ufrag": ufrag,
-            "pwd": pwd,
-            "candidates": self.media_candidates,
-        }
-
-    def on_action_new(
-        self, action_data_s: str, action_id: str, security_limit: int, profile: str
-    ) -> None:
-        """Called when a call is received
-
-        @param action_data_s: Action data serialized
-        @param action_id: Unique identifier for the action
-        @param security_limit: Security limit for the action
-        @param profile: Profile associated with the action
-        """
-        action_data = json.loads(action_data_s)
-        if action_data.get("type") != "call":
-            return
-        peer_jid = action_data["from_jid"]
-        log.info(
-            f"{peer_jid} wants to start a call ({action_data['sub_type']})"
-        )
-        if self.sid is not None:
-            log.warning(
-                f"already in a call ({self.sid}), can't receive a new call from "
-                f"{peer_jid}"
-            )
-            return
-        self.sid = action_data["session_id"]
-        log.debug(f"Call SID: {self.sid}")
-
-        # Answer the call
-        offer_sdp = action_data["sdp"]
-        aio.run(self.answer_call(offer_sdp, action_id))
-
-    def _on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None:
-        """Called when we have received answer SDP from responder
-
-        @param session_id: Session identifier
-        @param sdp: Session Description Protocol data
-        @param profile: Profile associated with the action
-        """
-        aio.run(self.on_call_accepted(session_id, sdp, profile))
-
-    def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None:
-        """Call has been terminated
-
-        @param session_id: Session identifier
-        @param data_s: Serialised additional data on why the call has ended
-        @param profile: Profile associated
-        """
-        if self.sid is None:
-            log.debug("there are no calls in progress")
-            return
-        if session_id != self.sid:
-            log.debug(
-                f"ignoring call_ended not linked to our call ({self.sid}): {session_id}"
-            )
-            return
-        aio.run(self.end_call())
-
-    async def on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None:
-        """Call has been accepted, connection can be established
-
-        @param session_id: Session identifier
-        @param sdp: Session Description Protocol data
-        @param profile: Profile associated
-        """
-        if self.sid != session_id:
-            log.debug(
-                f"Call ignored due to different session ID ({self.sid=} {session_id=})"
-            )
-            return
-        await self._peer_connection.setRemoteDescription({
-            "type": "answer",
-            "sdp": sdp
-        })
-        await self.on_ice_candidates_new(self.candidates_buffer)
-        self.candidates_buffer.clear()
-
-    def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None:
-        """Called when new ICE candidates are received
-
-        @param sid: Session identifier
-        @param candidates_s: ICE candidates serialized
-        @param profile: Profile associated with the action
-        """
-        if sid != self.sid:
-            log.debug(
-                f"ignoring peer ice candidates for {sid=} ({self.sid=})."
-            )
-            return
-        candidates = json.loads(candidates_s)
-        aio.run(self.on_ice_candidates_new(candidates))
-
-    async def on_ice_candidates_new(self, candidates: dict) -> None:
-        """Called when new ICE canidates are received from peer
-
-        @param candidates: Dictionary containing new ICE candidates
-        """
-        log.debug(f"new peer candidates received: {candidates}")
-        if (
-            self._peer_connection is None
-            or self._peer_connection.remoteDescription is None
-        ):
-            for media_type in ("audio", "video"):
-                media_candidates = candidates.get(media_type)
-                if media_candidates:
-                    buffer = self.candidates_buffer[media_type]
-                    buffer["candidates"].extend(media_candidates["candidates"])
-            return
-        for media_type, ice_data in candidates.items():
-            for candidate in ice_data["candidates"]:
-                candidate_sdp = self.build_ice_candidate(candidate)
-                try:
-                    sdp_mline_index = self.get_sdp_mline_index(media_type)
-                except Exception as e:
-                    log.warning(e)
-                    continue
-                ice_candidate = window.RTCIceCandidate.new({
-                    "candidate": candidate_sdp,
-                    "sdpMLineIndex": sdp_mline_index
-                }
-                                                           )
-                await self._peer_connection.addIceCandidate(ice_candidate)
-
-    def on_track(self, event):
-        """New track has been received from peer
-
-        @param event: Event associated with the new track
-        """
-        if event.streams and event.streams[0]:
-            remote_stream = event.streams[0]
-            document["remote_video"].srcObject = remote_stream
-        else:
-            if self.remote_stream is None:
-                self.remote_stream = window.MediaStream.new()
-                document["remote_video"].srcObject = self.remote_stream
-            self.remote_stream.addTrack(event.track)
-
-        document["call_btn"].classList.add("is-hidden")
-        document["hangup_btn"].classList.remove("is-hidden")
-
-    def on_negotiation_needed(self, event) -> None:
-        log.debug(f"on_negotiation_needed {event=}")
-        # TODO
-
-    async def answer_call(self, offer_sdp: str, action_id: str):
-        """We respond to the call"""
-        log.debug("answering call")
-        await self._create_peer_connection()
-
-        await self._peer_connection.setRemoteDescription({
-            "type": "offer",
-            "sdp": offer_sdp
-        })
-        await self.on_ice_candidates_new(self.candidates_buffer)
-        self.candidates_buffer.clear()
-        await self._get_user_media()
-
-        # Gather local ICE candidates
-        local_ice_data = await self._gather_ice_candidates(False)
-        self.local_candidates = local_ice_data["candidates"]
-
-        await bridge.action_launch(
-            action_id,
-            json.dumps({
-                "sdp": self._peer_connection.localDescription.sdp,
-            })
-        )
-
-    async def make_call(self, audio: bool = True, video: bool = True) -> None:
-        """Start a WebRTC call
-
-        @param audio: True if an audio flux is required
-        @param video: True if a video flux is required
-        """
-        await self._create_peer_connection()
-        await self._get_user_media(audio, video)
-        await self._gather_ice_candidates(True)
-        callee_jid = document["callee_jid"].value
-
-        call_data = {
-            "sdp": self._peer_connection.localDescription.sdp
-        }
-        log.info(f"calling {callee_jid!r}")
-        self.sid = await bridge.call_start(
-            callee_jid,
-            json.dumps(call_data)
-        )
-        log.debug(f"Call SID: {self.sid}")
-
-    async def end_call(self) -> None:
-        """Stop streaming and clean instance"""
-        document["hangup_btn"].classList.add("is-hidden")
-        document["call_btn"].classList.remove("is-hidden")
-        if self._peer_connection is None:
-            log.debug("There is currently no call to end.")
-        else:
-            self._peer_connection.removeEventListener("track", self.on_track)
-            self._peer_connection.removeEventListener("negotiationneeded", self.on_negotiation_needed)
-            self._peer_connection.removeEventListener("icecandidate", self.on_ice_candidate)
-            self._peer_connection.removeEventListener("icegatheringstatechange", self.on_ice_gathering_state_change)
-
-            local_video = document["local_video"]
-            remote_video = document["remote_video"]
-            if local_video.srcObject:
-                for track in local_video.srcObject.getTracks():
-                    track.stop()
-            if remote_video.srcObject:
-                for track in remote_video.srcObject.getTracks():
-                    track.stop()
-
-            self._peer_connection.close()
-            self.reset_instance()
-
-    async def hand_up(self) -> None:
-        """Terminate the call"""
-        session_id = self.sid
-        await self.end_call()
-        await bridge.call_end(
-            session_id,
-            ""
-        )
-
-
-webrtc_call = WebRTCCall()
-
-document["call_btn"].bind(
-    "click",
-    lambda __: aio.run(webrtc_call.make_call())
-)
-document["hangup_btn"].bind(
-    "click",
-    lambda __: aio.run(webrtc_call.hand_up())
-)
-
-bridge.register_signal("action_new", webrtc_call.on_action_new)
-bridge.register_signal("call_accepted", webrtc_call._on_call_accepted)
-bridge.register_signal("call_ended", webrtc_call._on_call_ended)
-bridge.register_signal("ice_candidates_new", webrtc_call._on_ice_candidates_new)
-
-loading.remove_loading_screen()
--- a/libervia/pages/calls/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,19 +0,0 @@
-#!/usr/bin/env python3
-
-
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from twisted.internet import defer
-import datetime
-import time
-from dateutil import tz
-
-from libervia.server.constants import Const as C
-
-log = getLogger(__name__)
-
-
-name = "calls"
-access = C.PAGES_ACCESS_PROFILE
-template = "call/call.html"
--- a/libervia/pages/chat/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,122 +0,0 @@
-#!/usr/bin/env python3
-
-from sat.core.i18n import _
-from twisted.internet import defer
-from sat.core.log import getLogger
-from sat.tools.common import data_objects
-from sat.tools.common import data_format
-from twisted.words.protocols.jabber import jid
-from libervia.server.constants import Const as C
-from libervia.server import session_iface
-
-
-log = getLogger(__name__)
-
-name = "chat"
-access = C.PAGES_ACCESS_PROFILE
-template = "chat/chat.html"
-dynamic = True
-
-
-def parse_url(self, request):
-    rdata = self.get_r_data(request)
-
-    try:
-        target_jid_s = self.next_path(request)
-    except IndexError:
-        # not chat jid, we redirect to jid selection page
-        self.page_redirect("chat_select", request)
-
-    try:
-        target_jid = jid.JID(target_jid_s)
-        if not target_jid.user:
-            raise ValueError(_("invalid jid for chat (no local part)"))
-    except Exception as e:
-        log.warning(
-            _("bad chat jid entered: {jid} ({msg})").format(jid=target_jid, msg=e)
-        )
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    else:
-        rdata["target"] = target_jid
-
-
-@defer.inlineCallbacks
-def prepare_render(self, request):
-    #  FIXME: bug on room filtering (currently display messages from all rooms)
-    session = self.host.get_session_data(request, session_iface.IWebSession)
-    template_data = request.template_data
-    rdata = self.get_r_data(request)
-    target_jid = rdata["target"]
-    profile = session.profile
-    profile_jid = session.jid
-
-    disco = yield self.host.bridge_call("disco_infos", target_jid.host, "", True, profile)
-    if "conference" in [i[0] for i in disco[1]]:
-        chat_type = C.CHAT_GROUP
-        join_ret = yield self.host.bridge_call(
-            "muc_join", target_jid.userhost(), "", "", profile
-        )
-        (already_joined,
-         room_jid_s,
-         occupants,
-         user_nick,
-         room_subject,
-         room_statuses,
-         __) = join_ret
-        template_data["subject"] = room_subject
-        template_data["room_statuses"] = room_statuses
-        own_jid = jid.JID(room_jid_s)
-        own_jid.resource = user_nick
-    else:
-        chat_type = C.CHAT_ONE2ONE
-        own_jid = profile_jid
-    rdata["chat_type"] = chat_type
-    template_data["own_jid"] = own_jid
-
-    self.register_signal(request, "message_new")
-    history = yield self.host.bridge_call(
-        "history_get",
-        profile_jid.userhost(),
-        target_jid.userhost(),
-        20,
-        True,
-        {},
-        profile,
-    )
-    authors = {m[2] for m in history}
-    identities = session.identities
-    for author in authors:
-        id_raw = yield self.host.bridge_call(
-            "identity_get", author, [], True, profile)
-        identities[author] = data_format.deserialise(id_raw)
-
-    template_data["messages"] = data_objects.Messages(history)
-    rdata['identities'] = identities
-    template_data["target_jid"] = target_jid
-    template_data["chat_type"] = chat_type
-
-
-def on_data(self, request, data):
-    session = self.host.get_session_data(request, session_iface.IWebSession)
-    rdata = self.get_r_data(request)
-    target = rdata["target"]
-    data_type = data.get("type", "")
-    if data_type == "msg":
-        message = data["body"]
-        mess_type = (
-            C.MESS_TYPE_GROUPCHAT
-            if rdata["chat_type"] == C.CHAT_GROUP
-            else C.MESS_TYPE_CHAT
-        )
-        log.debug("message received: {}".format(message))
-        self.host.bridge_call(
-            "message_send",
-            target.full(),
-            {"": message},
-            {},
-            mess_type,
-            "",
-            session.profile,
-        )
-    else:
-        log.warning("unknown message type: {type}".format(type=data_type))
--- a/libervia/pages/chat/select/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,45 +0,0 @@
-#!/usr/bin/env python3
-
-
-from sat.core.i18n import _
-from libervia.server.constants import Const as C
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from sat.tools.common import data_objects
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-name = "chat_select"
-access = C.PAGES_ACCESS_PROFILE
-template = "chat/select.html"
-
-
-@defer.inlineCallbacks
-def prepare_render(self, request):
-    profile = self.get_profile(request)
-    template_data = request.template_data
-    rooms = template_data["rooms"] = []
-    bookmarks = yield self.host.bridge_call("bookmarks_list", "muc", "all", profile)
-    for bm_values in list(bookmarks.values()):
-        for room_jid, room_data in bm_values.items():
-            url = self.get_page_by_name("chat").get_url(room_jid)
-            rooms.append(data_objects.Room(room_jid, name=room_data.get("name"), url=url))
-    rooms.sort(key=lambda r: r.name)
-
-
-@defer.inlineCallbacks
-def on_data_post(self, request):
-    jid_ = self.get_posted_data(request, "jid")
-    if "@" not in jid_:
-        profile = self.get_profile(request)
-        service = yield self.host.bridge_call("muc_get_service", "", profile)
-        if service:
-            muc_jid = jid.JID(service)
-            muc_jid.user = jid_
-            jid_ = muc_jid.full()
-        else:
-            log.warning(_("Invalid jid received: {jid}".format(jid=jid_)))
-            defer.returnValue(C.POST_NO_CONFIRM)
-    url = self.get_page_by_name("chat").get_url(jid_)
-    self.http_redirect(request, url)
--- a/libervia/pages/embed/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,26 +0,0 @@
-#!/usr/bin/env python3
-
-from sat.core.log import getLogger
-from sat.core import exceptions
-from libervia.server.constants import Const as C
-
-log = getLogger(__name__)
-
-name = "embed_app"
-template = "embed/embed.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["app_name"], min_args=1)
-    data = self.get_r_data(request)
-    app_name = data["app_name"]
-    try:
-        app_data = self.vhost_root.libervia_apps[app_name]
-    except KeyError:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    template_data = request.template_data
-    template_data['full_screen_body'] = True
-    try:
-        template_data["target_url"] = app_data["url_prefix"]
-    except KeyError:
-        raise exceptions.InternalError(f'"url_prefix" is missing for {app_name!r}')
--- a/libervia/pages/events/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,52 +0,0 @@
-from browser import DOMNode, document, aio
-from javascript import JSON
-from bridge import AsyncBridge as 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.interest_retract("", 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.ps_node_delete(
-                    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/events/admin/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,173 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from twisted.words.protocols.jabber import jid
-from sat.tools.common.template import safe
-from sat.tools.common import data_format
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-import time
-import html
-import math
-import re
-
-name = "event_admin"
-label = D_("Event Administration")
-access = C.PAGES_ACCESS_PROFILE
-template = "event/admin.html"
-log = getLogger(__name__)
-REG_EMAIL_RE = re.compile(C.REG_EMAIL_RE, re.IGNORECASE)
-
-
-def parse_url(self, request):
-    self.get_path_args(
-        request,
-        ("event_service", "event_node", "event_id"),
-        min_args=2,
-        event_service="@jid",
-        event_id="",
-    )
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-
-    ## Event ##
-
-    event_service = template_data["event_service"] = data["event_service"]
-    event_node = template_data["event_node"] = data["event_node"]
-    event_id = template_data["event_id"] = data["event_id"]
-    profile = self.get_profile(request)
-    event_timestamp, event_data = await self.host.bridge_call(
-        "eventGet",
-        event_service.userhost() if event_service else "",
-        event_node,
-        event_id,
-        profile,
-    )
-    try:
-        background_image = event_data.pop("background-image")
-    except KeyError:
-        pass
-    else:
-        template_data["dynamic_style"] = safe(
-            """
-            html {
-                background-image: url("%s");
-                background-size: 15em;
-            }
-            """
-            % html.escape(background_image, True)
-        )
-    template_data["event"] = event_data
-    invitees = await self.host.bridge_call(
-        "event_invitees_list",
-        event_data["invitees_service"],
-        event_data["invitees_node"],
-        profile,
-    )
-    template_data["invitees"] = invitees
-    invitees_guests = 0
-    for invitee_data in invitees.values():
-        if invitee_data.get("attend", "no") == "no":
-            continue
-        try:
-            invitees_guests += int(invitee_data.get("guests", 0))
-        except ValueError:
-            log.warning(
-                _("guests value is not valid: {invitee}").format(invitee=invitee_data)
-            )
-    template_data["invitees_guests"] = invitees_guests
-    template_data["days_left"] = int(
-        math.ceil((event_timestamp - time.time()) / (60 * 60 * 24))
-    )
-
-    ## Blog ##
-
-    data["service"] = jid.JID(event_data["blog_service"])
-    data["node"] = event_data["blog_node"]
-    data["allow_commenting"] = "simple"
-
-    # we now need blog items, using blog common page
-    # this will fill the "items" template data
-    blog_page = self.get_page_by_name("blog_view")
-    await blog_page.prepare_render(self, request)
-
-
-async def on_data_post(self, request):
-    profile = self.get_profile(request)
-    if not profile:
-        log.error("got post data without profile")
-        self.page_error(request, C.HTTP_INTERNAL_ERROR)
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "blog":
-        service, node, title, body, lang = self.get_posted_data(
-            request, ("service", "node", "title", "body", "language")
-        )
-
-        if not body.strip():
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-        data = {"content": body}
-        if title:
-            data["title"] = title
-        if lang:
-            data["language"] = lang
-        try:
-            comments = bool(self.get_posted_data(request, "comments").strip())
-        except KeyError:
-            pass
-        else:
-            if comments:
-                data["allow_comments"] = True
-
-        try:
-            await self.host.bridge_call(
-                "mb_send", service, node, data_format.serialise(data), profile)
-        except Exception as e:
-            if "forbidden" in str(e):
-                self.page_error(request, C.HTTP_FORBIDDEN)
-            else:
-                raise e
-    elif type_ == "event":
-        service, node, event_id, jids, emails = self.get_posted_data(
-            request, ("service", "node", "event_id", "jids", "emails")
-        )
-        for invitee_jid_s in jids.split():
-            try:
-                invitee_jid = jid.JID(invitee_jid_s)
-            except RuntimeError:
-                log.warning(
-                    _("this is not a valid jid: {jid}").format(jid=invitee_jid_s)
-                )
-                continue
-            await self.host.bridge_call(
-                "event_invite", invitee_jid.userhost(), service, node, event_id, profile
-            )
-        for email_addr in emails.split():
-            if not REG_EMAIL_RE.match(email_addr):
-                log.warning(
-                    _("this is not a valid email address: {email}").format(
-                        email=email_addr
-                    )
-                )
-                continue
-            await self.host.bridge_call(
-                "event_invite_by_email",
-                service,
-                node,
-                event_id,
-                email_addr,
-                {},
-                "",
-                "",
-                "",
-                "",
-                "",
-                "",
-                profile,
-            )
-
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/events/new/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,39 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from twisted.internet import defer
-from sat.core.log import getLogger
-from sat.tools.common import date_utils
-
-"""creation of new events"""
-
-name = "event_new"
-access = C.PAGES_ACCESS_PROFILE
-template = "event/create.html"
-log = getLogger(__name__)
-
-
-@defer.inlineCallbacks
-def on_data_post(self, request):
-    request_data = self.get_r_data(request)
-    profile = self.get_profile(request)
-    title, location, body, date, main_img, bg_img = self.get_posted_data(
-        request, ("name", "location", "body", "date", "main_image", "bg_image")
-    )
-    timestamp = date_utils.date_parse(date)
-    data = {"name": title, "description": body, "location": location}
-
-    for value, var in ((main_img, "image"), (bg_img, "background-image")):
-        value = value.strip()
-        if not value:
-            continue
-        if not value.startswith("http"):
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-        data[var] = value
-    data["register"] = C.BOOL_TRUE
-    node = yield self.host.bridge_call("event_create", timestamp, data, "", "", "", profile)
-    log.info("Event node created at {node}".format(node=node))
-
-    request_data["post_redirect_page"] = (self.get_page_by_name("event_admin"), "@", node)
-    defer.returnValue(C.POST_NO_CONFIRM)
--- a/libervia/pages/events/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,51 +0,0 @@
-#!/usr/bin/env python3
-
-
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from twisted.internet import defer
-
-from libervia.server.constants import Const as C
-
-log = getLogger(__name__)
-
-
-name = "events"
-access = C.PAGES_ACCESS_PUBLIC
-template = "event/overview.html"
-
-
-async def prepare_render(self, request):
-    profile = self.get_profile(request)
-    template_data = request.template_data
-    template_data["url_event_new"] = self.get_sub_page_url(request, "event_new")
-    if profile is not None:
-        try:
-            events = data_format.deserialise(
-                await self.host.bridge_call("events_get", "", "", [], "", profile),
-                type_check=list
-            )
-        except Exception as e:
-            log.warning(_("Can't get events list for {profile}: {reason}").format(
-                profile=profile, reason=e))
-        else:
-            own_events = []
-            other_events = []
-            for event in events:
-                if C.bool(event.get("creator", C.BOOL_FALSE)):
-                    own_events.append(event)
-                    event["url"] = self.get_sub_page_url(
-                        request,
-                        "event_admin",
-                    )
-                else:
-                    other_events.append(event)
-                    event["url"] = self.get_sub_page_url(
-                        request,
-                        "event_rsvp",
-                    )
-                if "thumb_url" not in event and "image" in event:
-                    event["thumb_url"] = event["image"]
-
-            template_data["events"] = own_events + other_events
--- a/libervia/pages/events/rsvp/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,101 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _, D_
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from sat.core.log import getLogger
-from sat.tools.common.template import safe
-import time
-import html
-
-"""creation of new events"""
-
-name = "event_rsvp"
-label = D_("Event Invitation")
-access = C.PAGES_ACCESS_PROFILE
-template = "event/invitation.html"
-log = getLogger(__name__)
-
-
-def parse_url(self, request):
-    self.get_path_args(
-        request,
-        ("event_service", "event_node", "event_id"),
-        min_args=2,
-        event_service="@jid",
-        event_id="",
-    )
-
-
-@defer.inlineCallbacks
-def prepare_render(self, request):
-    template_data = request.template_data
-    data = self.get_r_data(request)
-    profile = self.get_profile(request)
-
-    ## Event ##
-
-    event_service = data["event_service"]
-    event_node = data["event_node"]
-    event_id = data["event_id"]
-    event_timestamp, event_data = yield self.host.bridge_call(
-        "eventGet",
-        event_service.userhost() if event_service else "",
-        event_node,
-        event_id,
-        profile,
-    )
-    try:
-        background_image = event_data.pop("background-image")
-    except KeyError:
-        pass
-    else:
-        template_data["dynamic_style"] = safe(
-            """
-            html {
-                background-image: url("%s");
-                background-size: 15em;
-            }
-            """
-            % html.escape(background_image, True)
-        )
-    template_data["event"] = event_data
-    event_invitee_data = yield self.host.bridge_call(
-        "event_invitee_get",
-        event_data["invitees_service"],
-        event_data["invitees_node"],
-        '',
-        profile,
-    )
-    template_data["invitee"] = event_invitee_data
-    template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24))
-
-    ## Blog ##
-
-    data["service"] = jid.JID(event_data["blog_service"])
-    data["node"] = event_data["blog_node"]
-    data["allow_commenting"] = "simple"
-
-    # we now need blog items, using blog common page
-    # this will fill the "items" template data
-    blog_page = self.get_page_by_name("blog_view")
-    yield blog_page.prepare_render(self, request)
-
-
-@defer.inlineCallbacks
-def on_data_post(self, request):
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "comment":
-        blog_page = self.get_page_by_name("blog_view")
-        yield blog_page.on_data_post(self, request)
-    elif type_ == "attendance":
-        profile = self.get_profile(request)
-        service, node, attend, guests = self.get_posted_data(
-            request, ("service", "node", "attend", "guests")
-        )
-        data = {"attend": attend, "guests": guests}
-        yield self.host.bridge_call("event_invitee_set", service, node, data, profile)
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/events/view/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,97 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-from libervia.server import session_iface
-from sat.tools.common import uri
-from sat.tools.common.template import safe
-import time
-import html
-from sat.core.log import getLogger
-
-name = "event_view"
-access = C.PAGES_ACCESS_PROFILE
-template = "event/invitation.html"
-log = getLogger(__name__)
-
-
-@defer.inlineCallbacks
-def prepare_render(self, request):
-    template_data = request.template_data
-    guest_session = self.host.get_session_data(request, session_iface.ISATGuestSession)
-    try:
-        event_uri = guest_session.data["event_uri"]
-    except KeyError:
-        log.warning(_("event URI not found, can't render event page"))
-        self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE)
-
-    data = self.get_r_data(request)
-
-    ## Event ##
-
-    event_uri_data = uri.parse_xmpp_uri(event_uri)
-    if event_uri_data["type"] != "pubsub":
-        self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE)
-
-    event_service = template_data["event_service"] = jid.JID(event_uri_data["path"])
-    event_node = template_data["event_node"] = event_uri_data["node"]
-    event_id = template_data["event_id"] = event_uri_data.get("item", "")
-    profile = self.get_profile(request)
-    event_timestamp, event_data = yield self.host.bridge_call(
-        "eventGet", event_service.userhost(), event_node, event_id, profile
-    )
-    try:
-        background_image = event_data.pop("background-image")
-    except KeyError:
-        pass
-    else:
-        template_data["dynamic_style"] = safe(
-            """
-            html {
-                background-image: url("%s");
-                background-size: 15em;
-            }
-            """
-            % html.escape(background_image, True)
-        )
-    template_data["event"] = event_data
-    event_invitee_data = yield self.host.bridge_call(
-        "event_invitee_get",
-        event_data["invitees_service"],
-        event_data["invitees_node"],
-        '',
-        profile,
-    )
-    template_data["invitee"] = event_invitee_data
-    template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24))
-
-    ## Blog ##
-
-    data["service"] = jid.JID(event_data["blog_service"])
-    data["node"] = event_data["blog_node"]
-    data["allow_commenting"] = "simple"
-
-    # we now need blog items, using blog common page
-    # this will fill the "items" template data
-    blog_page = self.get_page_by_name("blog_view")
-    yield blog_page.prepare_render(self, request)
-
-
-@defer.inlineCallbacks
-def on_data_post(self, request):
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "comment":
-        blog_page = self.get_page_by_name("blog_view")
-        yield blog_page.on_data_post(self, request)
-    elif type_ == "attendance":
-        profile = self.get_profile(request)
-        service, node, attend, guests = self.get_posted_data(
-            request, ("service", "node", "attend", "guests")
-        )
-        data = {"attend": attend, "guests": guests}
-        yield self.host.bridge_call("event_invitee_set", service, node, data, profile)
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/files/list/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,152 +0,0 @@
-#!/usr/bin/env python3
-
-import json
-import os
-from pathlib import Path
-from sat.core.log import getLogger
-from sat.tools.common import uri
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from libervia.server.constants import Const as C
-from libervia.server import session_iface
-from libervia.server import pages_tools
-
-log = getLogger(__name__)
-"""files handling pages"""
-
-name = "files_list"
-access = C.PAGES_ACCESS_PROFILE
-template = "file/overview.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="")
-
-
-def add_breadcrumb(self, request, breadcrumbs):
-    data = self.get_r_data(request)
-    breadcrumbs.append({
-        "label": data["service"],
-        "url": self.get_url(data["service"].full()),
-        "icon": "server",
-    })
-    for idx, p in enumerate(data["path"]):
-        breadcrumbs.append({
-            "label": p,
-            "url": self.get_url(data["service"].full(), *data["path"][:idx+1]),
-            "icon": "folder-open-empty",
-        })
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    thumb_limit = data.get("thumb_limit", 400)
-    template_data = request.template_data
-    service, path_elts = data["service"], data["path"]
-    path = Path('/', *path_elts)
-    profile = self.get_profile(request) or C.SERVICE_PROFILE
-    session_data = self.host.get_session_data(
-        request, session_iface.IWebSession
-    )
-
-    try:
-        files_data = await self.host.bridge_call(
-            "fis_list", service.full(), str(path), {}, profile)
-    except BridgeException as e:
-        if e.condition == 'item-not-found':
-            log.debug(
-                f'"item-not-found" received for {path} at {service}, this may indicate '
-                f'that the location is new')
-            files_data = []
-        else:
-            raise e
-    for file_data in files_data:
-        try:
-            extra_raw = file_data["extra"]
-        except KeyError:
-            pass
-        else:
-            file_data["extra"] = json.loads(extra_raw) if extra_raw else {}
-        dir_path = path_elts + [file_data["name"]]
-        if file_data["type"] == C.FILE_TYPE_DIRECTORY:
-            page = self
-        elif file_data["type"] == C.FILE_TYPE_FILE:
-            page = self.get_page_by_name("files_view")
-
-            # we set URL for the last thumbnail which has a size below thumb_limit
-            try:
-                thumbnails = file_data["extra"]["thumbnails"]
-                thumb = thumbnails[0]
-                for thumb_data in thumbnails:
-                    if thumb_data["size"][0] > thumb_limit:
-                        break
-                    thumb = thumb_data
-                file_data["thumb_url"] = (
-                    thumb.get("url")
-                    or os.path.join(session_data.cache_dir, thumb["filename"])
-                )
-            except (KeyError, IndexError):
-                pass
-        else:
-            raise ValueError(
-                "unexpected file type: {file_type}".format(file_type=file_data["type"])
-            )
-        file_data["url"] = page.get_url(service.full(), *dir_path)
-
-        ## comments ##
-        comments_url = file_data.get("comments_url")
-        if comments_url:
-            parsed_url = uri.parse_xmpp_uri(comments_url)
-            comments_service = file_data["comments_service"] = parsed_url["path"]
-            comments_node = file_data["comments_node"] = parsed_url["node"]
-            try:
-                comments_count = file_data["comments_count"] = int(
-                    file_data["comments_count"]
-                )
-            except KeyError:
-                comments_count = None
-            if comments_count and data.get("retrieve_comments", False):
-                file_data["comments"] = await pages_tools.retrieve_comments(
-                    self, comments_service, comments_node, profile=profile
-                )
-
-    # parent dir affiliation
-    # TODO: some caching? What if affiliation changes?
-
-    try:
-        affiliations = await self.host.bridge_call(
-            "fis_affiliations_get", service.full(), "", str(path), profile
-        )
-    except BridgeException as e:
-        if e.condition == 'item-not-found':
-            log.debug(
-                f'"item-not-found" received for {path} at {service}, this may indicate '
-                f'that the location is new')
-            # FIXME: Q&D handling of empty dir (e.g. new directory/photos album)
-            affiliations = {
-                session_data.jid.userhost(): "owner"
-            }
-        if e.condition == "service-unavailable":
-            affiliations = {}
-        else:
-            raise e
-
-    directory_affiliation = affiliations.get(session_data.jid.userhost())
-    if directory_affiliation == "owner":
-        # we need to transtype dict items to str because with some bridges (D-Bus)
-        # we have a specific type which can't be exposed
-        self.expose_to_scripts(
-            request,
-            affiliations={str(e): str(a) for e, a in affiliations.items()}
-        )
-
-    template_data["directory_affiliation"] = directory_affiliation
-    template_data["files_data"] = files_data
-    template_data["path"] = path
-    self.expose_to_scripts(
-        request,
-        directory_affiliation=str(directory_affiliation),
-        files_service=service.full(),
-        files_path=str(path),
-    )
-    if path_elts:
-        template_data["parent_url"] = self.get_url(service.full(), *path_elts[:-1])
--- a/libervia/pages/files/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,52 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from twisted.words.protocols.jabber import jid
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-"""files handling pages"""
-
-name = "files"
-access = C.PAGES_ACCESS_PROFILE
-template = "file/discover.html"
-
-
-async def prepare_render(self, request):
-    profile = self.get_profile(request)
-    template_data = request.template_data
-    namespace = self.host.ns_map["fis"]
-    entities_services, entities_own, entities_roster = await self.host.bridge_call(
-        "disco_find_by_features", [namespace], [], False, True, True, True, False, profile
-    )
-    tpl_service_entities = template_data["disco_service_entities"] = {}
-    tpl_own_entities = template_data["disco_own_entities"] = {}
-    tpl_roster_entities = template_data["disco_roster_entities"] = {}
-    entities_url = template_data["entities_url"] = {}
-
-    # we store identities in dict of dict using category and type as keys
-    # this way it's easier to test category in the template
-    for tpl_entities, entities_map in (
-        (tpl_service_entities, entities_services),
-        (tpl_own_entities, entities_own),
-        (tpl_roster_entities, entities_roster),
-    ):
-        for entity_str, entity_ids in entities_map.items():
-            entity_jid = jid.JID(entity_str)
-            tpl_entities[entity_jid] = identities = {}
-            for cat, type_, name in entity_ids:
-                identities.setdefault(cat, {}).setdefault(type_, []).append(name)
-            entities_url[entity_jid] = self.get_page_by_name("files_list").get_url(
-                entity_jid.full()
-            )
-
-
-def on_data_post(self, request):
-    jid_str = self.get_posted_data(request, "jid")
-    try:
-        jid_ = jid.JID(jid_str)
-    except RuntimeError:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    url = self.get_page_by_name("files_list").get_url(jid_.full())
-    self.http_redirect(request, url)
--- a/libervia/pages/files/view/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,58 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _
-from twisted.web import static
-from libervia.server.utils import ProgressHandler
-import tempfile
-import os
-import os.path
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-"""files handling pages"""
-
-name = "files_view"
-access = C.PAGES_ACCESS_PROFILE
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "*path"], min_args=2, service="jid", path="")
-
-
-def cleanup(__, tmp_dir, dest_path):
-    try:
-        os.unlink(dest_path)
-    except OSError:
-        log.warning(_("Can't remove temporary file {path}").format(path=dest_path))
-    try:
-        os.rmdir(tmp_dir)
-    except OSError:
-        log.warning(_("Can't remove temporary directory {path}").format(path=tmp_dir))
-
-
-async def render(self, request):
-    data = self.get_r_data(request)
-    profile = self.get_profile(request)
-    service, path_elts = data["service"], data["path"]
-    basename = path_elts[-1]
-    dir_elts = path_elts[:-1]
-    dir_path = "/".join(dir_elts)
-    tmp_dir = tempfile.mkdtemp()
-    dest_path = os.path.join(tmp_dir, basename)
-    request.notifyFinish().addCallback(cleanup, tmp_dir, dest_path)
-    progress_id = await self.host.bridge_call(
-        "file_jingle_request",
-        service.full(),
-        dest_path,
-        basename,
-        "",
-        "",
-        {"path": dir_path},
-        profile,
-    )
-    log.debug("file requested")
-    await ProgressHandler(self.host, progress_id, profile).register()
-    log.debug("file downloaded")
-    self.delegate_to_resource(request, static.File(dest_path))
--- a/libervia/pages/forums/list/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,66 +0,0 @@
-#!/usr/bin/env python3
-
-from libervia.server.constants import Const as C
-from sat.core.log import getLogger
-from sat.core.i18n import _
-from sat.tools.common import uri as xmpp_uri
-
-log = getLogger(__name__)
-import json
-
-"""forum handling pages"""
-
-name = "forums"
-access = C.PAGES_ACCESS_PUBLIC
-template = "forum/overview.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(
-        request,
-        ["service", "node", "forum_key"],
-        service="@jid",
-        node="@",
-        forum_key="",
-    )
-
-
-def add_breadcrumb(self, request, breadcrumbs):
-    # we don't want breadcrumbs here as long as there is no forum discovery
-    # because it will be the landing page for forums activity until then
-    pass
-
-
-def get_links(self, forums):
-    for forum in forums:
-        try:
-            uri = forum["uri"]
-        except KeyError:
-            pass
-        else:
-            uri = xmpp_uri.parse_xmpp_uri(uri)
-            service = uri["path"]
-            node = uri["node"]
-            forum["http_url"] = self.get_page_by_name("forum_topics").get_url(service, node)
-        if "sub-forums" in forum:
-            get_links(self, forum["sub-forums"])
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    service, node, key = data["service"], data["node"], data["forum_key"]
-    profile = self.get_profile(request) or C.SERVICE_PROFILE
-
-    try:
-        forums_raw = await self.host.bridge_call(
-            "forums_get", service.full() if service else "", node, key, profile
-        )
-    except Exception as e:
-        log.warning(_("Can't retrieve forums: {msg}").format(msg=e))
-        forums = []
-    else:
-        forums = json.loads(forums_raw)
-    get_links(self, forums)
-
-    template_data["forums"] = forums
--- a/libervia/pages/forums/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,3 +0,0 @@
-
-def prepare_render(self, request):
-    self.page_redirect("forums", request, skip_parse_url=False)
--- a/libervia/pages/forums/topics/new/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,5 +0,0 @@
-import editor
-
-
-editor.set_form_autosave("forum_topic_edit")
-editor.BlogEditor("forum_topic_edit")
--- a/libervia/pages/forums/topics/new/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,51 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import D_
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-name = "forum_topic_new"
-label = D_("New Topic")
-access = C.PAGES_ACCESS_PROFILE
-template = "blog/publish.html"
-
-
-async def prepare_render(self, request):
-   template_data = request.template_data
-   template_data.update({
-       "post_form_id": "forum_topic_edit",
-       "publish_title": D_("New Forum Topic"),
-       "title_label": D_("Topic"),
-       "title_required": True,
-       "body_label": D_("Message"),
-       "no_tabs": True,
-   })
-
-
-async def on_data_post(self, request):
-    profile = self.get_profile(request)
-    if profile is None:
-        self.page_error(request, C.HTTP_FORBIDDEN)
-    rdata = self.get_r_data(request)
-    service = rdata["service"].full() if rdata["service"] else ""
-    node = rdata["node"]
-    title, body = self.get_posted_data(request, ("title", "body"))
-    title = title.strip()
-    body = body.strip()
-    if not title or not body:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    topic_data = {"title": title, "content": body}
-    try:
-        await self.host.bridge_call(
-            "forum_topic_create", service, node, topic_data, profile
-        )
-    except Exception as e:
-        if "forbidden" in str(e):
-            self.page_error(request, 401)
-        else:
-            raise e
-
-    rdata["post_redirect_page"] = (self.get_page_by_name("forum_topics"), service, node)
--- a/libervia/pages/forums/topics/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-from sat.tools.common import uri as xmpp_uri
-from sat.tools.common import data_format
-from libervia.server import session_iface
-
-log = getLogger(__name__)
-
-name = "forum_topics"
-label = D_("Forum Topics")
-access = C.PAGES_ACCESS_PUBLIC
-template = "forum/view_topics.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "node"], 2, service="jid")
-
-
-def add_breadcrumb(self, request, breadcrumbs):
-    data = self.get_r_data(request)
-    breadcrumbs.append({
-        "label": label,
-        "url": self.get_url(data["service"].full(), data["node"])
-    })
-
-
-async def prepare_render(self, request):
-    profile = self.get_profile(request) or C.SERVICE_PROFILE
-    data = self.get_r_data(request)
-    service, node = data["service"], data["node"]
-    request.template_data.update({"service": service, "node": node})
-    template_data = request.template_data
-    page_max = data.get("page_max", 20)
-    extra = self.get_pubsub_extra(request, page_max=page_max)
-    topics, metadata = await self.host.bridge_call(
-        "forum_topics_get",
-        service.full(),
-        node,
-        extra,
-        profile
-    )
-    metadata = data_format.deserialise(metadata)
-    self.set_pagination(request, metadata)
-    identities = self.host.get_session_data(
-        request, session_iface.IWebSession
-    ).identities
-    for topic in topics:
-        parsed_uri = xmpp_uri.parse_xmpp_uri(topic["uri"])
-        author = topic["author"]
-        topic["http_uri"] = self.get_page_by_name("forum_view").get_url(
-            parsed_uri["path"], parsed_uri["node"]
-        )
-        if author not in identities:
-            id_raw = await self.host.bridge_call(
-                "identity_get", author, [], True, profile
-            )
-            identities[topic["author"]] = data_format.deserialise(id_raw)
-
-    template_data["topics"] = topics
-    template_data["url_topic_new"] = self.get_sub_page_url(request, "forum_topic_new")
-
-
-async def on_data_post(self, request):
-    profile = self.get_profile(request)
-    if profile is None:
-        self.page_error(request, C.HTTP_FORBIDDEN)
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "new_topic":
-        service, node, title, body = self.get_posted_data(
-            request, ("service", "node", "title", "body")
-        )
-
-        if not title or not body:
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-        topic_data = {"title": title, "content": body}
-        try:
-            await self.host.bridge_call(
-                "forum_topic_create", service, node, topic_data, profile
-            )
-        except Exception as e:
-            if "forbidden" in str(e):
-                self.page_error(request, 401)
-            else:
-                raise e
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/forums/view/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,51 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-name = "forum_view"
-label = D_("View")
-access = C.PAGES_ACCESS_PUBLIC
-template = "forum/view.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "node"], 2, service="jid")
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    data["show_comments"] = False
-    blog_page = self.get_page_by_name("blog_view")
-    request.args[b"before"] = [b""]
-    request.args[b"reverse"] = [b"1"]
-    await blog_page.prepare_render(self, request)
-    request.template_data["login_url"] = self.get_page_redirect_url(request)
-
-
-async def on_data_post(self, request):
-    profile = self.get_profile(request)
-    if profile is None:
-        self.page_error(request, C.HTTP_FORBIDDEN)
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "comment":
-        service, node, body = self.get_posted_data(request, ("service", "node", "body"))
-
-        if not body:
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-        mb_data = {"content_rich": body}
-        try:
-            await self.host.bridge_call(
-                "mb_send", service, node, data_format.serialise(mb_data), profile)
-        except Exception as e:
-            if "forbidden" in str(e):
-                self.page_error(request, 401)
-            else:
-                raise e
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/g/e/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-#!/usr/bin/env python3
-
-
-redirect = "event_view"
--- a/libervia/pages/g/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,172 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _
-from libervia.server import session_iface
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-access = C.PAGES_ACCESS_PUBLIC
-template = "invitation/welcome.html"
-
-
-async def parse_url(self, request):
-    """check invitation id in URL and start session if needed
-
-    if a session already exists for an other guest/profile, it will be purged
-    """
-    try:
-        invitation_id = self.next_path(request)
-    except IndexError:
-        self.page_error(request)
-
-    web_session, guest_session = self.host.get_session_data(
-        request, session_iface.IWebSession, session_iface.ISATGuestSession
-    )
-    current_id = guest_session.id
-
-    if current_id is not None and current_id != invitation_id:
-        log.info(
-            _(
-                "killing guest session [{old_id}] because it is connecting with an other ID [{new_id}]"
-            ).format(old_id=current_id, new_id=invitation_id)
-        )
-        self.host.purge_session(request)
-        web_session, guest_session = self.host.get_session_data(
-            request, session_iface.IWebSession, session_iface.ISATGuestSession
-        )
-        current_id = None  # FIXME: id not reset here
-        profile = None
-
-    profile = web_session.profile
-    if profile is not None and current_id is None:
-        log.info(
-            _(
-                "killing current profile session [{profile}] because a guest id is used"
-            ).format(profile=profile)
-        )
-        self.host.purge_session(request)
-        web_session, guest_session = self.host.get_session_data(
-            request, session_iface.IWebSession, session_iface.ISATGuestSession
-        )
-        profile = None
-
-    if current_id is None:
-        log.debug(_("checking invitation [{id}]").format(id=invitation_id))
-        try:
-            data = await self.host.bridge_call("invitation_get", invitation_id)
-        except Exception:
-            self.page_error(request, C.HTTP_FORBIDDEN)
-        else:
-            guest_session.id = invitation_id
-            guest_session.data = data
-    else:
-        data = guest_session.data
-
-    if profile is None:
-        log.debug(_("connecting profile [{}]").format(profile))
-        # we need to connect the profile
-        profile = data["guest_profile"]
-        password = data["password"]
-        try:
-            await self.host.connect(request, profile, password)
-        except Exception as e:
-            log.warning(_("Can't connect profile: {msg}").format(msg=e))
-            # FIXME: no good error code correspond
-            #        maybe use a custom one?
-            self.page_error(request, code=C.HTTP_SERVICE_UNAVAILABLE)
-
-        log.info(
-            _(
-                "guest session started, connected with profile [{profile}]".format(
-                    profile=profile
-                )
-            )
-        )
-
-    # we copy data useful in templates
-    template_data = request.template_data
-    template_data["norobots"] = True
-    if "name" in data:
-        template_data["name"] = data["name"]
-    if "language" in data:
-        template_data["locale"] = data["language"]
-
-def handle_event_interest(self, interest):
-    if C.bool(interest.get("creator", C.BOOL_FALSE)):
-        page_name = "event_admin"
-    else:
-        page_name = "event_rsvp"
-
-    interest["url"] = self.get_page_by_name(page_name).get_url(
-        interest.get("service", ""),
-        interest.get("node", ""),
-        interest.get("item"),
-        )
-
-    if "thumb_url" not in interest and "image" in interest:
-        interest["thumb_url"] = interest["image"]
-
-def handle_fis_interest(self, interest):
-    path = interest.get('path', '')
-    path_args = [p for p in path.split('/') if p]
-    subtype = interest.get('subtype')
-
-    if subtype == 'files':
-        page_name = "files_view"
-    elif interest.get('subtype') == 'photos':
-        page_name = "photos_album"
-    else:
-        log.warning("unknown interest subtype: {subtype}".format(subtype=subtype))
-        return False
-
-    interest["url"] = self.get_page_by_name(page_name).get_url(
-        interest['service'], *path_args)
-
-async def prepare_render(self, request):
-    template_data = request.template_data
-    profile = self.get_profile(request)
-
-    # interests
-    template_data['interests_map'] = interests_map = {}
-    try:
-        interests = await self.host.bridge_call(
-            "interests_list", "", "", "", profile)
-    except Exception:
-        log.warning(_("Can't get interests list for {profile}").format(
-            profile=profile))
-    else:
-        # we only want known interests (photos and events for now)
-        # this dict map namespaces of interest to a callback which can manipulate
-        # the data. If it returns False, the interest is skipped
-        ns_data = {}
-
-        for short_name, cb in (('event', handle_event_interest),
-                               ('fis', handle_fis_interest),
-                              ):
-            try:
-                namespace = self.host.ns_map[short_name]
-            except KeyError:
-                pass
-            else:
-                ns_data[namespace] = (cb, short_name)
-
-        for interest in interests:
-            namespace = interest.get('namespace')
-            if namespace not in ns_data:
-                continue
-            cb, short_name = ns_data[namespace]
-            if cb(self, interest) == False:
-                continue
-            key = interest.get('subtype', short_name)
-            interests_map.setdefault(key, []).append(interest)
-
-    # main URI
-    guest_session = self.host.get_session_data(request, session_iface.ISATGuestSession)
-    main_uri = guest_session.data.get("event_uri")
-    if main_uri:
-        include_url = self.get_page_path_from_uri(main_uri)
-        if include_url is not None:
-            template_data["include_url"] = include_url
--- a/libervia/pages/lists/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,52 +0,0 @@
-from browser import DOMNode, document, aio
-from javascript import JSON
-from bridge import AsyncBridge as 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"List {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.interest_retract("", item['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.ps_node_delete(
-                    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/create/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,41 +0,0 @@
-#!/usr/bin/env python3
-
-from libervia.server.constants import Const as C
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-name = "list_create"
-access = C.PAGES_ACCESS_PROFILE
-template = "list/create.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["template_id"])
-    data = self.get_r_data(request)
-    if data["template_id"]:
-        self.http_redirect(
-            request,
-            self.get_page_by_name("list_create_from_tpl").get_url(data["template_id"])
-        )
-
-
-async def prepare_render(self, request):
-    template_data = request.template_data
-    profile = self.get_profile(request)
-    tpl_raw = await self.host.bridge_call(
-        "list_templates_names_get",
-        "",
-        profile,
-    )
-    lists_templates = data_format.deserialise(tpl_raw, type_check=list)
-    template_data["icons_names"] = {tpl['icon'] for tpl in lists_templates}
-    template_data["lists_templates"] = [
-        {
-            "icon": tpl["icon"],
-            "name": tpl["name"],
-            "url": self.get_url(tpl["id"]),
-        }
-        for tpl in lists_templates
-    ]
--- a/libervia/pages/lists/create_from_tpl/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,64 +0,0 @@
-#!/usr/bin/env python3
-
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-from sat.core.i18n import D_
-from sat.core import exceptions
-from libervia.server.constants import Const as C
-from sat_frontends.bridge.bridge_frontend import BridgeException
-
-log = getLogger(__name__)
-
-name = "list_create_from_tpl"
-access = C.PAGES_ACCESS_PROFILE
-template = "list/create_from_template.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["template_id"])
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_id = data["template_id"]
-    if not template_id:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    template_data = request.template_data
-    profile = self.get_profile(request)
-    tpl_raw = await self.host.bridge_call(
-        "list_template_get",
-        template_id,
-        "",
-        profile,
-    )
-    template = data_format.deserialise(tpl_raw)
-    template['id'] = template_id
-    template_data["list_template"] = template
-
-async def on_data_post(self, request):
-    data = self.get_r_data(request)
-    template_id = data['template_id']
-    name, access = self.get_posted_data(request, ('name', 'access'))
-    if access == 'private':
-        access_model = 'whitelist'
-    elif access == 'public':
-        access_model = 'open'
-    else:
-        log.warning(f"Unknown access for template creation: {access}")
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    profile = self.get_profile(request)
-    try:
-        service, node = await self.host.bridge_call(
-            "list_template_create", template_id, name, access_model, profile
-        )
-    except BridgeException as e:
-        if e.condition == "conflict":
-            raise exceptions.DataError(D_("A list with this name already exists"))
-        else:
-            log.error(f"Can't create list from template: {e}")
-            raise e
-    data["post_redirect_page"] = (
-        self.get_page_by_name("lists"),
-        service,
-        node or "@",
-    )
--- a/libervia/pages/lists/edit/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,113 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _
-from twisted.internet import defer
-from sat.tools.common import template_xmlui
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-name = "list_edit"
-access = C.PAGES_ACCESS_PROFILE
-template = "list/edit.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@")
-    data = self.get_r_data(request)
-    if data["item_id"] is None:
-        log.warning(_("no list item id specified"))
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-
-@defer.inlineCallbacks
-def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    service, node, item_id = (
-        data.get("service", ""),
-        data.get("node", ""),
-        data["item_id"],
-    )
-    profile = self.get_profile(request)
-
-    # we don't ignore "author" below to keep it when a list item is edited
-    # by node owner/admin and "consistent publisher" is activated
-    ignore = (
-        "publisher",
-        "author",
-        "author_jid",
-        "author_email",
-        "created",
-        "updated",
-        "comments_uri",
-    )
-    list_raw = yield self.host.bridge_call(
-        "list_get",
-        service.full() if service else "",
-        node,
-        C.NO_LIMIT,
-        [item_id],
-        "",
-        data_format.serialise({}),
-        profile,
-    )
-    list_items, metadata = data_format.deserialise(list_raw, type_check=list)
-    list_item = [template_xmlui.create(self.host, x, ignore=ignore) for x in list_items][0]
-
-    try:
-        # small trick to get a one line text input instead of the big textarea
-        list_item.widgets["labels"].type = "string"
-        list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace(
-            "\n", ", "
-        )
-    except KeyError:
-        pass
-
-    # for now we don't have XHTML editor, so we'll go with a TextBox and a convertion
-    # to a text friendly syntax using markdown
-    wid = list_item.widgets['body']
-    if wid.type == "xhtmlbox":
-        wid.type = "textbox"
-        wid.value =  yield self.host.bridge_call(
-            "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown",
-            False, profile)
-
-    template_data["new_list_item_xmlui"] = list_item
-
-
-async def on_data_post(self, request):
-    data = self.get_r_data(request)
-    service = data["service"]
-    node = data["node"]
-    item_id = data["item_id"]
-    posted_data = self.get_all_posted_data(request)
-    if not posted_data["title"] or not posted_data["body"]:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    try:
-        posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")]
-    except (KeyError, IndexError):
-        pass
-    profile = self.get_profile(request)
-
-    # we convert back body to XHTML
-    body = await self.host.bridge_call(
-        "syntax_convert", 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}
-    await self.host.bridge_call(
-        "list_set", service.full(), node, posted_data, "", item_id,
-        data_format.serialise(extra), profile
-    )
-    data["post_redirect_page"] = (
-        self.get_page_by_name("list_view"),
-        service.full(),
-        node or "@",
-        item_id
-    )
-    return C.POST_NO_CONFIRM
--- a/libervia/pages/lists/new/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,89 +0,0 @@
-#!/usr/bin/env python3
-
-from libervia.server.constants import Const as C
-from sat.tools.common import template_xmlui
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-name = "list_new"
-access = C.PAGES_ACCESS_PROFILE
-template = "list/create_item.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "node"], service="jid", node="@")
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    service, node = data.get("service", ""), data.get("node", "")
-    profile = self.get_profile(request)
-    schema = await self.host.bridge_call("list_schema_get", service.full(), node, profile)
-    data["schema"] = schema
-    # following fields are handled in backend
-    ignore = (
-        "author",
-        "author_jid",
-        "author_email",
-        "created",
-        "updated",
-        "comments_uri",
-        "status",
-        "milestone",
-        "priority",
-    )
-    xmlui_obj = template_xmlui.create(self.host, schema, ignore=ignore)
-    try:
-        # small trick to get a one line text input instead of the big textarea
-        xmlui_obj.widgets["labels"].type = "string"
-    except KeyError:
-        pass
-
-    try:
-        wid = xmlui_obj.widgets['body']
-    except KeyError:
-        pass
-    else:
-        if wid.type == "xhtmlbox":
-            # same as for list_edit, we have to convert for now
-            wid.type = "textbox"
-            wid.value =  await self.host.bridge_call(
-                "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown",
-                False, profile)
-    template_data["new_list_item_xmlui"] = xmlui_obj
-
-
-async def on_data_post(self, request):
-    data = self.get_r_data(request)
-    service = data["service"]
-    node = data["node"]
-    posted_data = self.get_all_posted_data(request)
-    if (("title" in posted_data and not posted_data["title"])
-         or ("body" in posted_data and not posted_data["body"])):
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    try:
-        posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")]
-    except (KeyError, IndexError):
-        pass
-    profile = self.get_profile(request)
-
-    # we convert back body to XHTML
-    if "body" in posted_data:
-        body = await self.host.bridge_call(
-            "syntax_convert", 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)]
-
-
-    await self.host.bridge_call(
-        "list_set", service.full(), node, posted_data, "", "", "", profile
-    )
-    # we don't want to redirect to creation page on success, but to list overview
-    data["post_redirect_page"] = (
-        self.get_page_by_name("lists"),
-        service.full(),
-        node or "@",
-    )
--- a/libervia/pages/lists/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,71 +0,0 @@
-#!/usr/bin/env python3
-
-from libervia.server.constants import Const as C
-from twisted.words.protocols.jabber import jid
-from sat.core.i18n import _, D_
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-
-log = getLogger(__name__)
-
-name = "lists_disco"
-label = D_("Lists Discovery")
-access = C.PAGES_ACCESS_PUBLIC
-template = "list/discover.html"
-
-async def prepare_render(self, request):
-    profile = self.get_profile(request)
-    template_data = request.template_data
-    template_data["url_list_create"] = self.get_page_by_name("list_create").url
-    lists_directory_config = self.host.options["lists_directory_json"]
-    lists_directory = request.template_data["lists_directory"] = []
-
-    if lists_directory_config:
-        try:
-            for list_data in lists_directory_config:
-                service = list_data["service"]
-                node = list_data["node"]
-                name = list_data["name"]
-                url = self.get_page_by_name("lists").get_url(service, node)
-                lists_directory.append({"name": name, "url": url, "from_config": True})
-        except KeyError as e:
-            log.warning("Missing field in lists_directory_json: {msg}".format(msg=e))
-        except Exception as e:
-            log.warning("Can't decode lists directory: {msg}".format(msg=e))
-
-    if profile is not None:
-        try:
-            lists_list_raw = await self.host.bridge_call("lists_list", "", "", profile)
-        except Exception as e:
-            log.warning(
-                _("Can't get list of registered lists for {profile}: {reason}")
-                .format(profile=profile, reason=e)
-            )
-        else:
-            lists_list = data_format.deserialise(lists_list_raw, type_check=list)
-            for list_data in lists_list:
-                service = list_data["service"]
-                node = list_data["node"]
-                list_data["url"] = self.get_page_by_name("lists").get_url(service, node)
-                list_data["from_config"] = False
-                lists_directory.append(list_data)
-
-    icons_names = set()
-    for list_data in lists_directory:
-        try:
-            icons_names.add(list_data['icon_name'])
-        except KeyError:
-            pass
-    if icons_names:
-        template_data["icons_names"] = icons_names
-
-
-def on_data_post(self, request):
-    jid_str = self.get_posted_data(request, "jid")
-    try:
-        jid_ = jid.JID(jid_str)
-    except RuntimeError:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    # for now we just use default node
-    url = self.get_page_by_name("lists").get_url(jid_.full(), "@")
-    self.http_redirect(request, url)
--- a/libervia/pages/lists/view/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,133 +0,0 @@
-from browser import window, document, aio, bind
-from invitation import InvitationManager
-from javascript import JSON
-from bridge import Async as Bridge, BridgeException
-import dialog
-
-
-bridge = Bridge()
-lists_ns = window.lists_ns
-pubsub_service = window.pubsub_service
-pubsub_node = window.pubsub_node
-list_type = window.list_type
-try:
-    affiliations = window.affiliations.to_dict()
-except AttributeError:
-    pass
-
-@bind("#button_manage", "click")
-def manage_click(evt):
-    evt.stopPropagation()
-    evt.preventDefault()
-    pubsub_data = {
-        "namespace": lists_ns,
-        "service": pubsub_service,
-        "node": pubsub_node
-    }
-    try:
-        name = pubsub_node.split('_', 1)[1]
-    except IndexError:
-        pass
-    else:
-        name = name.strip()
-        if name:
-            pubsub_data['name'] = name
-    manager = InvitationManager("pubsub", pubsub_data)
-    manager.attach(affiliations=affiliations)
-
-
-async def on_delete(evt):
-    item_elt = evt.target.closest(".item")
-    if item_elt is None:
-        dialog.notification.show(
-            "Can't find parent item element",
-            level="error"
-        )
-        return
-    item_elt.classList.add("selected_for_deletion")
-    item = JSON.parse(item_elt.dataset.item)
-    confirmed = await dialog.Confirm(
-        f"{item['name']!r} will be deleted, are you sure?",
-        ok_label="delete",
-        ok_color="danger",
-    ).ashow()
-    item_elt.classList.remove("selected_for_deletion")
-    if confirmed:
-        try:
-            await bridge.ps_item_retract(pubsub_service, pubsub_node, item["id"], True)
-        except Exception as e:
-            dialog.notification.show(
-                f"Can't delete list item: {e}",
-                level="error"
-            )
-        else:
-            dialog.notification.show("list item deleted successfuly")
-            item_elt.remove()
-
-
-async def on_next_state(evt):
-    """Update item with next state
-
-    Only used with grocery list at the moment
-    """
-    evt.stopPropagation()
-    evt.preventDefault()
-    # FIXME: states are currently hardcoded, it would be better to use schema
-    item_elt = evt.target.closest(".item")
-    if item_elt is None:
-        dialog.notification.show(
-            "Can't find parent item element",
-            level="error"
-        )
-        return
-    item = JSON.parse(item_elt.dataset.item)
-    try:
-        status = item["status"]
-    except (KeyError, IndexError) as e:
-        dialog.notification.show(
-            f"Can't get item status: {e}",
-            level="error"
-        )
-        status = "to_buy"
-    if status == "to_buy":
-        item["status"] = "bought"
-        class_update_method = item_elt.classList.add
-        checked = True
-    elif status == "bought":
-        item["status"] = "to_buy"
-        checked = False
-        class_update_method = item_elt.classList.remove
-    else:
-        dialog.notification.show(
-            f"unexpected item status: {status!r}",
-            level="error"
-        )
-        return
-    item_elt.dataset.item = JSON.stringify(item)
-    try:
-        await bridge.list_set(
-            pubsub_service,
-            pubsub_node,
-            # FIXME: value type should be consistent, or we should serialise
-            {k: (v if isinstance(v, list) else [v]) for k,v in item.items()},
-            "",
-            item["id"],
-            ""
-        )
-    except BridgeException as e:
-        dialog.notification.show(
-            f"Can't udate list item: {e.message}",
-            level="error"
-        )
-    else:
-        evt.target.checked = checked
-        class_update_method("list-item-closed")
-
-
-if list_type == "grocery":
-    for elt in document.select('.click_to_delete'):
-        elt.bind("click", lambda evt: aio.run(on_delete(evt)))
-
-    for elt in document.select('.click_to_next_state'):
-        elt.bind("click", lambda evt: aio.run(on_next_state(evt)))
-
--- a/libervia/pages/lists/view/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,152 +0,0 @@
-#!/usr/bin/env python3
-
-from sat.tools.common import template_xmlui
-from sat.tools.common import data_objects
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from libervia.server.constants import Const as C
-
-log = getLogger(__name__)
-
-name = "lists"
-access = C.PAGES_ACCESS_PUBLIC
-template = "list/overview.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "node"], service="jid")
-    data = self.get_r_data(request)
-    service, node = data["service"], data["node"]
-    if node is None:
-        self.http_redirect(request, self.get_page_by_name("lists_disco").url)
-    if node == "@":
-        node = data["node"] = ""
-    template_data = request.template_data
-    template_data["url_list_items"] = self.get_url(service.full(), node or "@")
-    template_data["url_list_new"] = self.get_page_by_name("list_new").get_url(
-        service.full(), node or "@")
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    service, node = data["service"], data["node"]
-    submitted_node = await self.host.bridge_call(
-        "ps_schema_submitted_node_get",
-        node or self.host.ns_map["tickets"]
-    )
-    profile = self.get_profile(request) or C.SERVICE_PROFILE
-
-    self.check_cache(request, C.CACHE_PUBSUB, service=service, node=node, short="tickets")
-
-    try:
-        lists_types = self.get_page_data(request, "lists_types")
-        if lists_types is None:
-            lists_types = {}
-            self.set_page_data(request, "lists_types", lists_types)
-        list_type = lists_types[(service, node)]
-    except KeyError:
-        ns_tickets_type = self.host.ns_map["tickets_type"]
-        schema_raw = await self.host.bridge_call(
-            "ps_schema_dict_get",
-            service.full(),
-            submitted_node,
-            profile
-        )
-        schema = data_format.deserialise(schema_raw)
-        try:
-            list_type_field = next(
-                f for f in schema["fields"] if f["type"] == "hidden"
-                and f.get("name") == ns_tickets_type
-            )
-        except StopIteration:
-            list_type = lists_types[(service, node)] = None
-        else:
-            if list_type_field.get("value") is None:
-                list_type = None
-            else:
-                list_type = list_type_field["value"].lower().strip()
-            lists_types[(service, node)] = list_type
-
-    data["list_type"] = template_data["list_type"] = list_type
-
-    extra = self.get_pubsub_extra(request)
-    extra["labels_as_list"] = C.BOOL_TRUE
-    self.handle_search(request, extra)
-
-    list_raw = await self.host.bridge_call(
-        "list_get",
-        service.full() if service else "",
-        node,
-        C.NO_LIMIT,
-        [],
-        "",
-        data_format.serialise(extra),
-        profile,
-    )
-    if profile != C.SERVICE_PROFILE:
-        try:
-            affiliations = await self.host.bridge_call(
-                "ps_node_affiliations_get",
-                service.full() if service else "",
-                submitted_node,
-                profile
-            )
-        except BridgeException as 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.get_jid(request).userhost()) == 'owner'
-            template_data["owner"] = is_owner
-            if is_owner:
-                self.expose_to_scripts(
-                    request,
-                    affiliations={str(e): str(a) for e, a in affiliations.items()}
-                )
-    else:
-        template_data["owner"] = False
-
-    list_items, metadata = data_format.deserialise(list_raw, type_check=list)
-    template_data["list_items"] = [
-        template_xmlui.create(self.host, x) for x in list_items
-    ]
-    view_url = self.get_page_by_name('list_view').get_url(service.full(), node or '@')
-    template_data["on_list_item_click"] = data_objects.OnClick(
-        url=f"{view_url}/{{item.id}}"
-    )
-    self.set_pagination(request, metadata)
-    self.expose_to_scripts(
-        request,
-        lists_ns=self.host.ns_map["tickets"],
-        pubsub_service=service.full(),
-        pubsub_node=node,
-        list_type=list_type,
-    )
-
-
-async def on_data_post(self, request):
-    data = self.get_r_data(request)
-    profile = self.get_profile(request)
-    service = data["service"]
-    node = data["node"]
-    list_type = self.get_posted_data(request, ("type",))
-    if list_type == "grocery":
-        name, quantity = self.get_posted_data(request, ("name", "quantity"))
-        if not name:
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-        item_data = {
-            "name": [name],
-        }
-        if quantity:
-            item_data["quantity"] = [quantity]
-        await self.host.bridge_call(
-            "list_set", service.full(), node, item_data, "", "", "", profile
-        )
-        return C.POST_NO_CONFIRM
-    else:
-        raise NotImplementedError(
-            f"Can't use quick list item set for list of type {list_type!r}"
-        )
--- a/libervia/pages/lists/view_item/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,57 +0,0 @@
-from browser import document, window, aio
-from bridge import AsyncBridge as Bridge
-import dialog
-
-try:
-    pubsub_service = window.pubsub_service
-    pubsub_node = window.pubsub_node
-    pubsub_item = window.pubsub_item
-except AttributeError:
-    can_delete = False
-else:
-    bridge = Bridge()
-    can_delete = True
-
-
-async def on_delete(evt):
-    evt.stopPropagation()
-    confirmed = await dialog.Confirm(
-        "This item will be deleted, are you sure?",
-        ok_label="delete",
-        ok_color="danger",
-    ).ashow()
-    if confirmed:
-        try:
-            comments_service = window.comments_service
-            comments_node = window.comments_node
-        except AttributeError:
-            pass
-        else:
-            print(f"deleting comment node at [{comments_service}] {comments_node!r}")
-            try:
-                await bridge.ps_node_delete(comments_service, comments_node)
-            except Exception as e:
-                dialog.notification.show(
-                    f"Can't delete comment node: {e}",
-                    level="error"
-                )
-
-        print(f"deleting list item {pubsub_item!r} at [{pubsub_service}] {pubsub_node!r}")
-        try:
-            await bridge.ps_item_retract(pubsub_service, pubsub_node, pubsub_item, True)
-        except Exception as e:
-            dialog.notification.show(
-                f"Can't delete list item: {e}",
-                level="error"
-            )
-        else:
-            # FIXME: Q&D way to get list view URL, need to have a proper method (would
-            #   be nice to have a way to reference pages by name from browser)
-            list_url = '/'.join(window.location.pathname.split('/')[:-1]).replace(
-                'view_item', 'view')
-            window.location.replace(list_url)
-
-
-if can_delete:
-    for elt in document.select('.action_delete'):
-        elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- a/libervia/pages/lists/view_item/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,140 +0,0 @@
-#!/usr/bin/env python3
-
-from twisted.words.protocols.jabber import jid
-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
-from sat.core.log import getLogger
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from libervia.server.constants import Const as C
-from libervia.server.utils import SubPage
-from libervia.server import session_iface
-
-log = getLogger(__name__)
-
-
-name = "list_view"
-access = C.PAGES_ACCESS_PUBLIC
-template = "list/item.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@")
-    data = self.get_r_data(request)
-    if data["item_id"] is None:
-        log.warning(_("no list item id specified"))
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-
-
-def add_breadcrumb(self, request, breadcrumbs):
-    data = self.get_r_data(request)
-    list_url = self.get_page_by_name("lists").get_url(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.get_r_data(request)
-    template_data = request.template_data
-    session = self.host.get_session_data(request, session_iface.IWebSession)
-    service, node, item_id = (
-        data.get("service", ""),
-        data.get("node", ""),
-        data["item_id"],
-    )
-    namespace = node or self.host.ns_map["tickets"]
-    node = await self.host.bridge_call("ps_schema_submitted_node_get", namespace)
-
-    profile = self.get_profile(request)
-    if profile is None:
-        profile = C.SERVICE_PROFILE
-
-    list_raw = await self.host.bridge_call(
-        "list_get",
-        service.full() if service else "",
-        node,
-        C.NO_LIMIT,
-        [item_id],
-        "",
-        data_format.serialise({"labels_as_list": C.BOOL_TRUE}),
-        profile,
-    )
-    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:
-        comments_uri = list_item.widgets["comments_uri"].value
-    except KeyError:
-        pass
-    else:
-        if comments_uri:
-            uri_data = uri.parse_xmpp_uri(comments_uri)
-            template_data["comments_node"] = comments_node = uri_data["node"]
-            template_data["comments_service"] = comments_service = uri_data["path"]
-            try:
-                comments = data_format.deserialise(await self.host.bridge_call(
-                    "mb_get", comments_service, comments_node, C.NO_LIMIT, [],
-                    data_format.serialise({}), profile
-                ))
-            except BridgeException as e:
-                if e.classname == 'NotFound' or e.condition == 'item-not-found':
-                    log.warning(
-                        _("Can't find comment node at [{service}] {node!r}")
-                        .format(service=comments_service, node=comments_node)
-                    )
-                else:
-                    raise e
-            else:
-                template_data["comments"] = comments
-                template_data["login_url"] = self.get_page_redirect_url(request)
-                self.expose_to_scripts(
-                    request,
-                    comments_node=comments_node,
-                    comments_service=comments_service,
-                )
-
-    if session.connected:
-        # we activate modification action (edit, delete) only if user is the publisher or
-        # the node owner
-        publisher = jid.JID(list_item.widgets["publisher"].value)
-        is_publisher = publisher.userhostJID() == session.jid.userhostJID()
-        affiliation = None
-        if not is_publisher:
-            affiliation = await self.host.get_affiliation(request, service, node)
-        if is_publisher or affiliation == "owner":
-            self.expose_to_scripts(
-                request,
-                pubsub_service = service.full(),
-                pubsub_node = node,
-                pubsub_item = item_id,
-            )
-            template_data["can_modify"] = True
-            template_data["url_list_item_edit"] = self.get_url_by_path(
-                SubPage("list_edit"),
-                service.full(),
-                node or "@",
-                item_id,
-            )
-
-    # we add xmpp: URI
-    uri_args = {'path': service.full()}
-    uri_args['node'] = node
-    if item_id:
-        uri_args['item'] = item_id
-    template_data['xmpp_uri'] = uri.build_xmpp_uri('pubsub', **uri_args)
-
-
-async def on_data_post(self, request):
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "comment":
-        blog_page = self.get_page_by_name("blog_view")
-        await blog_page.on_data_post(self, request)
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/login/logged/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,18 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server import session_iface
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-"""SàT log-in page, with link to create an account"""
-
-template = "login/logged.html"
-
-
-def prepare_render(self, request):
-    template_data = request.template_data
-    session_data = self.host.get_session_data(request, session_iface.IWebSession)
-    template_data["guest_session"] = session_data.guest
-    template_data["session_started"] = session_data.started
--- a/libervia/pages/login/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,92 +0,0 @@
-#!/usr/bin/env python3
-
-
-from sat.core.i18n import _
-from sat.core import exceptions
-from libervia.server.constants import Const as C
-from libervia.server import session_iface
-from twisted.internet import defer
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-"""SàT log-in page, with link to create an account"""
-
-name = "login"
-access = C.PAGES_ACCESS_PUBLIC
-template = "login/login.html"
-
-
-def prepare_render(self, request):
-    template_data = request.template_data
-
-    #  we redirect to logged page if a session is active
-    profile = self.get_profile(request)
-    if profile is not None:
-        self.page_redirect("/login/logged", request)
-
-    # login error message
-    session_data = self.host.get_session_data(request, session_iface.IWebSession)
-    login_error = session_data.pop_page_data(self, "login_error")
-    if login_error is not None:
-        template_data["S_C"] = C  # we need server constants in template
-        template_data["login_error"] = login_error
-    template_data["empty_password_allowed"] = bool(
-        self.host.options["empty_password_allowed_warning_dangerous_list"]
-    )
-
-    # register page url
-    if self.host.options["allow_registration"]:
-        template_data["register_url"] = self.get_page_redirect_url(request, "register")
-
-    #  if login is set, we put it in template to prefill field
-    template_data["login"] = session_data.pop_page_data(self, "login")
-
-
-def login_error(self, request, error_const):
-    """set login_error in page data
-
-    @param error_const(unicode): one of login error constant
-    @return C.POST_NO_CONFIRM: avoid confirm message
-    """
-    session_data = self.host.get_session_data(request, session_iface.IWebSession)
-    session_data.set_page_data(self, "login_error", error_const)
-    return C.POST_NO_CONFIRM
-
-
-async def on_data_post(self, request):
-    profile = self.get_profile(request)
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "disconnect":
-        if profile is None:
-            log.warning(_("Disconnect called when no profile is logged"))
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-        else:
-            self.host.purge_session(request)
-            return C.POST_NO_CONFIRM
-    elif type_ == "login":
-        login, password = self.get_posted_data(request, ("login", "password"))
-        try:
-            status = await self.host.connect(request, login, password)
-        except exceptions.ProfileUnknownError:
-            # the profile doesn't exist, we return the same error as for invalid password
-            # to avoid bruteforcing valid profiles
-            log.warning(f"login tentative with invalid profile: {login!r}")
-            return login_error(self, request, C.PROFILE_AUTH_ERROR)
-        except ValueError as e:
-            message = str(e)
-            if message in (C.XMPP_AUTH_ERROR, C.PROFILE_AUTH_ERROR):
-                return login_error(self, request, message)
-            else:
-                # this error was not expected!
-                raise e
-        except exceptions.TimeOutError:
-            return login_error(self, request, C.NO_REPLY)
-        else:
-            if status in (C.PROFILE_LOGGED, C.PROFILE_LOGGED_EXT_JID, C.SESSION_ACTIVE):
-                # Profile has been logged correctly
-                self.redirect_or_continue(request)
-            else:
-                log.error(_("Unhandled status: {status}".format(status=status)))
-    else:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
--- a/libervia/pages/merge-requests/disco/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,41 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from twisted.words.protocols.jabber import jid
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-name = "merge-requests_disco"
-access = C.PAGES_ACCESS_PUBLIC
-template = "merge-request/discover.html"
-
-
-def prepare_render(self, request):
-    mr_handlers_config = self.host.options["mr_handlers_json"]
-    if mr_handlers_config:
-        handlers = request.template_data["mr_handlers"] = []
-        try:
-            for handler_data in mr_handlers_config:
-                service = handler_data["service"]
-                node = handler_data["node"]
-                name = handler_data["name"]
-                url = self.get_page_by_name("merge-requests").get_url(service, node)
-                handlers.append({"name": name, "url": url})
-        except KeyError as e:
-            log.warning("Missing field in mr_handlers_json: {msg}".format(msg=e))
-        except Exception as e:
-            log.warning("Can't decode mr handlers: {msg}".format(msg=e))
-
-
-def on_data_post(self, request):
-    jid_str = self.get_posted_data(request, "jid")
-    try:
-        jid_ = jid.JID(jid_str)
-    except RuntimeError:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    # for now we just use default node
-    url = self.get_page_by_name("merge-requests").get_url(jid_.full(), "@")
-    self.http_redirect(request, url)
--- a/libervia/pages/merge-requests/edit/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,125 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _
-from sat.tools.common import template_xmlui
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-"""merge-requests edition"""
-
-name = "merge-requests_edit"
-access = C.PAGES_ACCESS_PROFILE
-template = "merge-request/edit.html"
-log = getLogger(__name__)
-
-
-def parse_url(self, request):
-    try:
-        item_id = self.next_path(request)
-    except IndexError:
-        log.warning(_("no list item id specified"))
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    data = self.get_r_data(request)
-    data["list_item_id"] = item_id
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    service, node, list_item_id = (
-        data.get("service", ""),
-        data.get("node", ""),
-        data["list_item_id"],
-    )
-    profile = self.get_profile(request)
-
-    ignore = (
-        "publisher",
-        "author",
-        "author_jid",
-        "author_email",
-        "created",
-        "updated",
-        "comments_uri",
-        "request_data",
-        "type",
-    )
-    merge_requests = data_format.deserialise(
-        await self.host.bridge_call(
-            "merge_requests_get",
-            service.full() if service else "",
-            node,
-            C.NO_LIMIT,
-            [list_item_id],
-            "",
-            data_format.serialise({}),
-            profile,
-        )
-    )
-    list_item = template_xmlui.create(
-        self.host, merge_requests['items'][0], ignore=ignore
-    )
-
-    try:
-        # small trick to get a one line text input instead of the big textarea
-        list_item.widgets["labels"].type = "string"
-        list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace(
-            "\n", ", "
-        )
-    except KeyError:
-        pass
-
-    # same as list_edit
-    wid = list_item.widgets['body']
-    if wid.type == "xhtmlbox":
-        wid.type = "textbox"
-        wid.value =  await self.host.bridge_call(
-            "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown",
-            False, profile)
-
-    template_data["new_list_item_xmlui"] = list_item
-
-
-async def on_data_post(self, request):
-    data = self.get_r_data(request)
-    service = data["service"]
-    node = data["node"]
-    list_item_id = data["list_item_id"]
-    posted_data = self.get_all_posted_data(request)
-    if not posted_data["title"] or not posted_data["body"]:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    try:
-        posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")]
-    except (KeyError, IndexError):
-        pass
-    profile = self.get_profile(request)
-
-    # we convert back body to XHTML
-    body = await self.host.bridge_call(
-        "syntax_convert", 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}
-    await self.host.bridge_call(
-        "merge_request_set",
-        service.full(),
-        node,
-        "",
-        "auto",
-        posted_data,
-        "",
-        list_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.get_page_by_name("merge-requests"),
-        service.full(),
-        node or "@",
-    )
--- a/libervia/pages/merge-requests/new/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,12 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-name = "merge-requests_new"
-access = C.PAGES_ACCESS_PUBLIC
-template = "merge-request/create.html"
--- a/libervia/pages/merge-requests/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,60 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.tools.common import template_xmlui
-from sat.tools.common import data_format
-from sat.tools.common import data_objects
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-
-name = "merge-requests"
-access = C.PAGES_ACCESS_PUBLIC
-template = "list/overview.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "node"], service="jid")
-    data = self.get_r_data(request)
-    service, node = data["service"], data["node"]
-    if node is None:
-        self.page_redirect("merge-requests_disco", request)
-    if node == "@":
-        node = data["node"] = ""
-    self.check_cache(
-        request, C.CACHE_PUBSUB, service=service, node=node, short="merge-requests"
-    )
-    template_data = request.template_data
-    template_data["url_list_items"] = self.get_page_by_name("merge-requests").get_url(
-        service.full(), node
-    )
-    template_data["url_list_new"] = self.get_sub_page_url(request, "merge-requests_new")
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    service, node = data["service"], data["node"]
-    profile = self.get_profile(request) or C.SERVICE_PROFILE
-
-    merge_requests = data_format.deserialise(
-        await self.host.bridge_call(
-            "merge_requests_get",
-            service.full() if service else "",
-            node,
-            C.NO_LIMIT,
-            [],
-            "",
-            data_format.serialise({"labels_as_list": C.BOOL_TRUE}),
-            profile,
-        )
-    )
-
-    template_data["list_items"] = [
-        template_xmlui.create(self.host, x) for x in merge_requests['items']
-    ]
-    template_data["on_list_item_click"] = data_objects.OnClick(
-        url=self.get_sub_page_url(request, "merge-requests_view") + "/{item.id}"
-    )
--- a/libervia/pages/merge-requests/view/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,98 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from sat.core.i18n import _
-from libervia.server.utils import SubPage
-from libervia.server import session_iface
-from twisted.words.protocols.jabber import jid
-from sat.tools.common import template_xmlui
-from sat.tools.common import uri
-from sat.tools.common import data_format
-from sat.core.log import getLogger
-
-name = "merge-requests_view"
-access = C.PAGES_ACCESS_PUBLIC
-template = "merge-request/item.html"
-log = getLogger(__name__)
-
-
-def parse_url(self, request):
-    try:
-        item_id = self.next_path(request)
-    except IndexError:
-        log.warning(_("no list item id specified"))
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-
-    data = self.get_r_data(request)
-    data["list_item_id"] = item_id
-
-
-async def prepare_render(self, request):
-    data = self.get_r_data(request)
-    template_data = request.template_data
-    session = self.host.get_session_data(request, session_iface.IWebSession)
-    service, node, list_item_id = (
-        data.get("service", ""),
-        data.get("node", ""),
-        data["list_item_id"],
-    )
-    profile = self.get_profile(request)
-
-    if profile is None:
-        profile = C.SERVICE_PROFILE
-
-    merge_requests = data_format.deserialise(
-        await self.host.bridge_call(
-            "merge_requests_get",
-            service.full() if service else "",
-            node,
-            C.NO_LIMIT,
-            [list_item_id],
-            "",
-            data_format.serialise({"parse": C.BOOL_TRUE, "labels_as_list": C.BOOL_TRUE}),
-            profile,
-        )
-    )
-    list_item = template_xmlui.create(
-        self.host, merge_requests['items'][0], ignore=["request_data", "type"]
-    )
-    template_data["item"] = list_item
-    template_data["patches"] = merge_requests['items_patches'][0]
-    comments_uri = list_item.widgets["comments_uri"].value
-    if comments_uri:
-        uri_data = uri.parse_xmpp_uri(comments_uri)
-        template_data["comments_node"] = comments_node = uri_data["node"]
-        template_data["comments_service"] = comments_service = uri_data["path"]
-        template_data["comments"] = data_format.deserialise(await self.host.bridge_call(
-            "mb_get", comments_service, comments_node, C.NO_LIMIT, [],
-            data_format.serialise({}), profile
-        ))
-
-        template_data["login_url"] = self.get_page_redirect_url(request)
-
-    if session.connected:
-        # we set edition URL only if user is the publisher or the node owner
-        publisher = jid.JID(list_item.widgets["publisher"].value)
-        is_publisher = publisher.userhostJID() == session.jid.userhostJID()
-        affiliation = None
-        if not is_publisher:
-            node = node or self.host.ns_map["merge_requests"]
-            affiliation = await self.host.get_affiliation(request, service, node)
-        if is_publisher or affiliation == "owner":
-            template_data["url_list_item_edit"] = self.get_url_by_path(
-                SubPage("merge-requests"),
-                service.full(),
-                node or "@",
-                SubPage("merge-requests_edit"),
-                list_item_id,
-            )
-
-
-async def on_data_post(self, request):
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "comment":
-        blog_page = self.get_page_by_name("blog_view")
-        await blog_page.on_data_post(self, request)
-    else:
-        log.warning(_("Unhandled data type: {}").format(type_))
--- a/libervia/pages/photos/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,62 +0,0 @@
-from browser import window, bind, DOMNode
-from javascript import JSON
-from bridge import Bridge
-import dialog
-
-bridge = Bridge()
-
-
-def album_delete_cb(item_elt, item):
-    print(f"deleted {item['name']}")
-
-
-def album_delete_eb(failure, item_elt, item):
-    # TODO: cleaner error notification
-    window.alert(f"error while deleting {item['name']}: failure")
-
-
-def interest_retract_cb(item_elt, item):
-    print(f"{item['name']} removed successfuly from list of interests")
-    item_elt.classList.add("state_deleted")
-    item_elt.bind("transitionend", lambda evt: item_elt.remove())
-    bridge.file_sharing_delete(
-        item['service'],
-        item.get('path', ''),
-        item.get('files_namespace', ''),
-        callback=lambda __: album_delete_cb(item_elt, item),
-        errback=lambda failure: album_delete_eb(failure, item_elt, item),
-    )
-
-
-def interest_retract_eb(failure_, item_elt, item):
-    # TODO: cleaner error notification
-    window.alert(f"Can't delete album {item['name']}: {failure_['message']}")
-
-
-def delete_ok(evt, notif_elt, item_elt, item):
-    bridge.interest_retract(
-        "", item['id'],
-        callback=lambda: interest_retract_cb(item_elt, item),
-        errback=lambda failure:interest_retract_eb(failure, item_elt, item))
-
-
-def delete_cancel(evt, notif_elt, item_elt, item):
-    notif_elt.remove()
-    item_elt.classList.remove("selected_for_deletion")
-
-
-@bind(".action_delete", "click")
-def on_delete(evt):
-    evt.stopPropagation()
-    target = evt.currentTarget
-    item_elt = DOMNode(target.closest('.item'))
-    item_elt.classList.add("selected_for_deletion")
-    item = JSON.parse(item_elt.dataset.item)
-    dialog.Confirm(
-        f"album {item['name']!r} will be deleted (inluding all its photos), "
-        f"are you sure?",
-        ok_label="delete",
-    ).show(
-        ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item),
-        cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item),
-    )
--- a/libervia/pages/photos/album/_browser/__init__.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,305 +0,0 @@
-from browser import document, window, bind, html, DOMNode, aio
-from javascript import JSON
-from bridge import Bridge, AsyncBridge
-from template import Template
-import dialog
-from slideshow import SlideShow
-from invitation import InvitationManager
-import alt_media_player
-# we use tmp_aio because `blob` is not handled in Brython's aio
-import tmp_aio
-import loading
-
-
-cache_path = window.cache_path
-files_service = window.files_service
-files_path = window.files_path
-try:
-    affiliations = window.affiliations.to_dict()
-except AttributeError:
-    pass
-bridge = Bridge()
-async_bridge = AsyncBridge()
-
-alt_media_player.install_if_needed()
-
-photo_tpl = Template('photo/item.html')
-player_tpl = Template('components/media_player.html')
-
-# file upload
-
-def on_progress(ev, photo_elt):
-    if ev.lengthComputable:
-        percent = int(ev.loaded/ev.total*100)
-        update_progress(photo_elt, percent)
-
-
-def on_load(file_, photo_elt):
-    update_progress(photo_elt, 100)
-    photo_elt.classList.add("progress_finished")
-    photo_elt.classList.remove("progress_started")
-    photo_elt.select_one('.action_delete').bind("click", on_delete)
-    print(f"file {file_.name} uploaded correctly")
-
-
-def on_error(failure, file_, photo_elt):
-    dialog.notification.show(
-        f"can't upload {file_.name}: {failure}",
-        level="error"
-    )
-
-
-def update_progress(photo_elt, new_value):
-    progress_elt = photo_elt.select_one("progress")
-    progress_elt.value = new_value
-    progress_elt.text = f"{new_value}%"
-
-
-def on_slot_cb(file_, upload_slot, photo_elt):
-    put_url, get_url, headers = upload_slot
-    xhr = window.XMLHttpRequest.new()
-    xhr.open("PUT", put_url, True)
-    xhr.upload.bind('progress', lambda ev: on_progress(ev, photo_elt))
-    xhr.upload.bind('load', lambda ev: on_load(file_, photo_elt))
-    xhr.upload.bind('error', lambda ev: on_error(xhr.response, file_, photo_elt))
-    xhr.setRequestHeader('Xmpp-File-Path', window.encodeURIComponent(files_path))
-    xhr.setRequestHeader('Xmpp-File-No-Http', "true")
-    xhr.send(file_)
-
-
-def on_slot_eb(file_, failure, photo_elt):
-    dialog.notification.show(
-        f"Can't get upload slot: {failure['message']}",
-        level="error"
-    )
-    photo_elt.remove()
-
-
-def upload_files(files):
-    print(f"uploading {len(files)} files")
-    album_items = document['album_items']
-    for file_ in files:
-        url = window.URL.createObjectURL(file_)
-        photo_elt = photo_tpl.get_elt({
-            "file": {
-                "name": file_.name,
-                # we don't want to open the file on click, it's not yet the
-                # uploaded URL
-                "url": url,
-                # we have no thumb yet, so we use the whole image
-                # TODO: reduce image for preview
-                "thumb_url": url,
-            },
-            "uploading": True,
-        })
-        photo_elt.classList.add("progress_started")
-        album_items <= photo_elt
-
-        bridge.file_http_upload_get_slot(
-            file_.name,
-            file_.size,
-            file_.type or '',
-            files_service,
-            callback=lambda upload_slot, file_=file_, photo_elt=photo_elt:
-                on_slot_cb(file_, upload_slot, photo_elt),
-            errback=lambda failure, file_=file_, photo_elt=photo_elt:
-                on_slot_eb(file_, failure, photo_elt),
-        )
-
-
-@bind("#file_drop", "drop")
-def on_file_select(evt):
-    evt.stopPropagation()
-    evt.preventDefault()
-    files = evt.dataTransfer.files
-    upload_files(files)
-
-
-@bind("#file_drop", "dragover")
-def on_drag_over(evt):
-    evt.stopPropagation()
-    evt.preventDefault()
-    evt.dataTransfer.dropEffect = 'copy'
-
-
-@bind("#file_input", "change")
-def on_file_input_change(evt):
-    files = evt.currentTarget.files
-    upload_files(files)
-
-# delete
-
-def file_delete_cb(item_elt, item):
-    item_elt.classList.add("state_deleted")
-    item_elt.bind("transitionend", lambda evt: item_elt.remove())
-    print(f"deleted {item['name']}")
-
-
-def file_delete_eb(failure, item_elt, item):
-    dialog.notification.show(
-        f"error while deleting {item['name']}: failure",
-        level="error"
-    )
-
-
-def delete_ok(evt, notif_elt, item_elt, item):
-    file_path = f"{files_path.rstrip('/')}/{item['name']}"
-    bridge.file_sharing_delete(
-        files_service,
-        file_path,
-        "",
-        callback=lambda : file_delete_cb(item_elt, item),
-        errback=lambda failure: file_delete_eb(failure, item_elt, item),
-    )
-
-
-def delete_cancel(evt, notif_elt, item_elt, item):
-    notif_elt.remove()
-    item_elt.classList.remove("selected_for_deletion")
-
-
-def on_delete(evt):
-    evt.stopPropagation()
-    target = evt.currentTarget
-    item_elt = DOMNode(target.closest('.item'))
-    item_elt.classList.add("selected_for_deletion")
-    item = JSON.parse(item_elt.dataset.item)
-    dialog.Confirm(
-        f"{item['name']!r} will be deleted, are you sure?",
-        ok_label="delete",
-        ok_color="danger",
-    ).show(
-        ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item),
-        cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item),
-    )
-
-# cover
-
-async def cover_ok(evt, notif_elt, item_elt, item):
-    # we first need to get a blob of the image
-    img_elt = item_elt.select_one("img")
-    # the simplest way is to download it
-    r = await tmp_aio.ajax("GET", img_elt.src, "blob")
-    if r.status != 200:
-        dialog.notification.show(
-            f"can't retrieve cover: {r.status}: {r.statusText}",
-            level="error"
-        )
-        return
-    img_blob = r.response
-    # now we'll upload it via HTTP Upload, we need a slow
-    img_name = img_elt.src.rsplit('/', 1)[-1]
-    img_size = img_blob.size
-
-    slot = await async_bridge.file_http_upload_get_slot(
-        img_name,
-        img_size,
-        '',
-        files_service
-    )
-    get_url, put_url, headers = slot
-    # we have the slot, we can upload image
-    r = await tmp_aio.ajax("PUT", put_url, "", img_blob)
-    if r.status != 201:
-        dialog.notification.show(
-            f"can't upload cover: {r.status}: {r.statusText}",
-            level="error"
-        )
-        return
-    extra = {"thumb_url": get_url}
-    album_name = files_path.rsplit('/', 1)[-1]
-    await async_bridge.interests_file_sharing_register(
-        files_service,
-        "photos",
-        "",
-        files_path,
-        album_name,
-        JSON.stringify(extra),
-    )
-    dialog.notification.show("Album cover has been changed")
-
-
-def cover_cancel(evt, notif_elt, item_elt, item):
-    notif_elt.remove()
-    item_elt.classList.remove("selected_for_action")
-
-
-def on_cover(evt):
-    evt.stopPropagation()
-    target = evt.currentTarget
-    item_elt = DOMNode(target.closest('.item'))
-    item_elt.classList.add("selected_for_action")
-    item = JSON.parse(item_elt.dataset.item)
-    dialog.Confirm(
-        f"use {item['name']!r} for this album cover?",
-        ok_label="use as cover",
-    ).show(
-        ok_cb=lambda evt, notif_elt: aio.run(cover_ok(evt, notif_elt, item_elt, item)),
-        cancel_cb=lambda evt, notif_elt: cover_cancel(evt, notif_elt, item_elt, item),
-    )
-
-
-# slideshow
-
-@bind(".photo_thumb_click", "click")
-def photo_click(evt):
-    evt.stopPropagation()
-    evt.preventDefault()
-    slideshow = SlideShow()
-    target = evt.currentTarget
-    clicked_item_elt = DOMNode(target.closest('.item'))
-
-    slideshow.attach()
-    for idx, item_elt in enumerate(document.select('.item')):
-        item = JSON.parse(item_elt.dataset.item)
-        try:
-            biggest_thumb = item['extra']['thumbnails'][-1]
-            thumb_url = f"{cache_path}{biggest_thumb['filename']}"
-        except (KeyError, IndexError) as e:
-            print(f"Can't get full screen thumbnail URL: {e}")
-            thumb_url = None
-        if item.get("mime_type", "")[:5] == "video":
-            player = alt_media_player.MediaPlayer(
-                [item['url']],
-                poster = thumb_url,
-                reduce_click_area =  True
-            )
-            elt = player.elt
-            elt.classList.add("slide_video", "no_fullscreen")
-            slideshow.add_slide(
-                elt,
-                item,
-                options={
-                    "flags": (alt_media_player.NO_PAGINATION, alt_media_player.NO_SCROLLBAR),
-                    "exit_callback": player.reset,
-                }
-            )
-        else:
-            slideshow.add_slide(html.IMG(src=thumb_url or item['url'], Class="slide_img"), item)
-        if item_elt == clicked_item_elt:
-            slideshow.index = idx
-
-
-for elt in document.select('.action_delete'):
-    elt.bind("click", on_delete)
-for elt in document.select('.action_cover'):
-    elt.bind("click", on_cover)
-
-# manage
-
-
-@bind("#button_manage", "click")
-def manage_click(evt):
-    evt.stopPropagation()
-    evt.preventDefault()
-    manager = InvitationManager("photos", {"service": files_service, "path": files_path})
-    manager.attach(affiliations=affiliations)
-
-
-# hint
-@bind("#hint .click_to_delete", "click")
-def remove_hint(evt):
-    document['hint'].remove()
-
-
-loading.remove_loading_screen()
--- a/libervia/pages/photos/album/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,30 +0,0 @@
-#!/usr/bin/env python3
-
-
-from sat.core.i18n import D_
-from sat.core.log import getLogger
-from libervia.server.constants import Const as C
-
-log = getLogger(__name__)
-
-name = "photos_album"
-label = D_("Photos Album")
-access = C.PAGES_ACCESS_PROFILE
-template = "photo/album.html"
-
-
-def parse_url(self, request):
-    self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="")
-
-
-def prepare_render(self, request):
-    data = self.get_r_data(request)
-    data["thumb_limit"] = 800
-    data["retrieve_comments"] = True
-    files_page = self.get_page_by_name("files_list")
-    return files_page.prepare_render(self, request)
-
-
-def on_data_post(self, request):
-    blog_page = self.get_page_by_name("blog_view")
-    return blog_page.on_data_post(self, request)
--- a/libervia/pages/photos/new/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-#!/usr/bin/env python3
-
-from libervia.server.constants import Const as C
-from twisted.internet import defer
-from sat.core.log import getLogger
-from sat.core.i18n import D_
-from sat.core import exceptions
-from sat_frontends.bridge.bridge_frontend import BridgeException
-
-"""creation of new events"""
-
-name = "photos_new"
-access = C.PAGES_ACCESS_PROFILE
-template = "photo/create.html"
-log = getLogger(__name__)
-
-
-async def on_data_post(self, request):
-    request_data = self.get_r_data(request)
-    profile = self.get_profile(request)
-    name = self.get_posted_data(request, "name").replace('/', '_')
-    albums_path = "/albums"
-    album_path = f"{albums_path}/{name}"
-    if profile is None:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
-    fis_ns = self.host.ns_map["fis"]
-    http_upload_ns = self.host.ns_map["http_upload"]
-    entities_services, __, __ = await self.host.bridge_call(
-        "disco_find_by_features",
-        [fis_ns, http_upload_ns],
-        [],
-        False,
-        True,
-        False,
-        False,
-        False,
-        profile
-    )
-    try:
-        fis_service = next(iter(entities_services))
-    except StopIteration:
-        raise exceptions.DataError(D_(
-           "You server has no service to create a photo album, please ask your server "
-           "administrator to add one"))
-
-    try:
-        await self.host.bridge_call(
-            "fis_create_dir",
-            fis_service,
-            "",
-            albums_path,
-            {"access_model": "open"},
-            profile
-        )
-    except BridgeException as e:
-        if e.condition == 'conflict':
-            pass
-        else:
-            log.error(f"Can't create {albums_path} path: {e}")
-            raise e
-
-    try:
-        await self.host.bridge_call(
-            "fis_create_dir",
-            fis_service,
-            "",
-            album_path,
-            {"access_model": "whitelist"},
-            profile
-        )
-    except BridgeException as e:
-        if e.condition == 'conflict':
-            pass
-        else:
-            log.error(f"Can't create {album_path} path: {e}")
-            raise e
-
-    await self.host.bridge_call(
-        "interests_file_sharing_register",
-        fis_service,
-        "photos",
-        "",
-        album_path,
-        name,
-        "",
-        profile
-    )
-    log.info(f"album {name} created")
-    request_data["post_redirect_page"] = self.get_page_by_name("photos")
-    defer.returnValue(C.POST_NO_CONFIRM)
--- a/libervia/pages/photos/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,53 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from twisted.internet import defer
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-name = "photos"
-access = C.PAGES_ACCESS_PROFILE
-template = "photo/discover.html"
-
-
-@defer.inlineCallbacks
-def prepare_render(self, request):
-    profile = self.get_profile(request)
-    template_data = request.template_data
-    namespace = self.host.ns_map["fis"]
-    if profile is not None:
-        try:
-            interests = yield self.host.bridge_call(
-                "interests_list", "", "", namespace, profile)
-        except Exception:
-            log.warning(_("Can't get interests list for {profile}").format(
-                profile=profile))
-        else:
-            # we only want photo albums
-            filtered_interests = []
-            for interest in interests:
-                if interest.get('subtype') != 'photos':
-                    continue
-                path = interest.get('path', '')
-                path_args = [p for p in path.split('/') if p]
-                interest["url"] = self.get_sub_page_url(
-                    request,
-                    "photos_album",
-                    interest['service'],
-                    *path_args
-                )
-                filtered_interests.append(interest)
-
-            template_data['interests'] = filtered_interests
-
-        template_data["url_photos_new"] = self.get_sub_page_url(request, "photos_new")
-
-
-@defer.inlineCallbacks
-def on_data_post(self, request):
-    jid_ = self.get_posted_data(request, "jid")
-    url = self.get_page_by_name("photos_album").get_url(jid_)
-    self.http_redirect(request, url)
--- a/libervia/pages/register/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,63 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from libervia.server import session_iface
-from twisted.internet import defer
-from sat.core.log import getLogger
-
-log = getLogger(__name__)
-
-"""SàT account registration page"""
-
-name = "register"
-access = C.PAGES_ACCESS_PUBLIC
-template = "login/register.html"
-
-
-def prepare_render(self, request):
-    if not self.host.options["allow_registration"]:
-        self.page_error(request, C.HTTP_FORBIDDEN)
-    profile = self.get_profile(request)
-    if profile is not None:
-        self.page_redirect("/login/logged", request)
-    template_data = request.template_data
-    template_data["login_url"] = self.get_page_by_name("login").url
-    template_data["S_C"] = C  # we need server constants in template
-
-    # login error message
-    session_data = self.host.get_session_data(request, session_iface.IWebSession)
-    login_error = session_data.pop_page_data(self, "login_error")
-    if login_error is not None:
-        template_data["login_error"] = login_error
-
-    #  if fields were already filled, we reuse them
-    for k in ("login", "email", "password"):
-        template_data[k] = session_data.pop_page_data(self, k)
-
-
-@defer.inlineCallbacks
-def on_data_post(self, request):
-    type_ = self.get_posted_data(request, "type")
-    if type_ == "register":
-        login, email, password = self.get_posted_data(
-            request, ("login", "email", "password")
-        )
-        status = yield self.host.register_new_account(request, login, password, email)
-        session_data = self.host.get_session_data(request, session_iface.IWebSession)
-        if status == C.REGISTRATION_SUCCEED:
-            # we prefill login field for login page
-            session_data.set_page_data(self.get_page_by_name("login"), "login", login)
-            # if we have a redirect_url we follow it
-            self.redirect_or_continue(request)
-            # else we redirect to login page
-            self.http_redirect(request, self.get_page_by_name("login").url)
-        else:
-            session_data.set_page_data(self, "login_error", status)
-            l = locals()
-            for k in ("login", "email", "password"):
-                # we save fields so user doesn't have to enter them again
-                session_data.set_page_data(self, k, l[k])
-            defer.returnValue(C.POST_NO_CONFIRM)
-    else:
-        self.page_error(request, C.HTTP_BAD_REQUEST)
--- a/libervia/pages/u/atom.xml/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,4 +0,0 @@
-#!/usr/bin/env python3
-
-redirect = "blog_feed_atom"
-name = "user_blog_feed_atom"
--- a/libervia/pages/u/blog/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,15 +0,0 @@
-#!/usr/bin/env python3
-
-
-name = "user_blog"
-
-
-def parse_url(self, request):
-    # in this subpage, we want path args and query args
-    # (i.e. what's remaining in URL: filters, id, etc.)
-    # to be used by blog's url parser, so we don't skip parse_url
-    data = self.get_r_data(request)
-    service = data["service"]
-    self.page_redirect(
-        "blog_view", request, skip_parse_url=False, path_args=[service.full(), "@"]
-    )
--- a/libervia/pages/u/page_meta.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,57 +0,0 @@
-#!/usr/bin/env python3
-
-
-from libervia.server.constants import Const as C
-from twisted.internet import defer
-from twisted.words.protocols.jabber import jid
-
-"""page used to target a user profile, e.g. for public blog"""
-
-name = "user"
-access = C.PAGES_ACCESS_PUBLIC  # can be a callable
-template = "blog/articles.html"
-url_cache = True
-
-
-@defer.inlineCallbacks
-def parse_url(self, request):
-    try:
-        prof_requested = self.next_path(request)
-    except IndexError:
-        self.page_error(request)
-
-    data = self.get_r_data(request)
-
-    target_profile = yield self.host.bridge_call("profile_name_get", prof_requested)
-    request.template_data["target_profile"] = target_profile
-    target_jid = yield self.host.bridge_call(
-        "param_get_a_async", "JabberID", "Connection", "value", profile_key=target_profile
-    )
-    target_jid = jid.JID(target_jid)
-    data["service"] = target_jid
-
-    # if URL is parsed here, we'll have atom.xml available and we need to
-    # add the link to the page
-    atom_url = self.get_sub_page_url(request, 'user_blog_feed_atom')
-    request.template_data['atom_url'] = atom_url
-    request.template_data.setdefault('links', []).append({
-        "href": atom_url,
-        "type": "application/atom+xml",
-        "rel": "alternate",
-        "title": "{target_profile}'s blog".format(target_profile=target_profile)})
-
-def add_breadcrumb(self, request, breadcrumbs):
-    # we don't want a breadcrumb here
-    pass
-
-
-@defer.inlineCallbacks
-def prepare_render(self, request):
-    data = self.get_r_data(request)
-    self.check_cache(
-        request, C.CACHE_PUBSUB, service=data["service"], node=None, short="microblog"
-    )
-    self.page_redirect("blog_view", request)
-
-def on_data_post(self, request):
-    return self.get_page_by_name("blog_view").on_data_post(self, request)
--- a/libervia/server/classes.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,27 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Useful genertic classes used in Libervia"""
-
-
-from collections import namedtuple
-
-WebsocketMeta = namedtuple("WebsocketMeta", ("url", "token", "debug"))
-Notification = namedtuple("Notification", ("message", "level"))
-Script = namedtuple("Script", ("src", "type", "content"), defaults=(None, None, ""))
--- a/libervia/server/constants.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,138 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a SàT frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from ..common import constants
-
-
-class Const(constants.Const):
-
-    APP_NAME = "Libervia Web"
-    APP_COMPONENT = "web"
-    APP_NAME_ALT = APP_NAME
-    APP_NAME_FILE = "libervia_web"
-    CONFIG_SECTION = APP_COMPONENT.lower()
-    # 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/"
-    THEMES_DIR = "themes/"
-    THEMES_URL = "themes"
-    MEDIA_DIR = "media/"
-    CARDS_DIR = "games/cards/tarot"
-    PAGES_DIR = "pages"
-    TASKS_DIR = "tasks"
-    LIBERVIA_CACHE = "libervia"
-    SITE_NAME_DEFAULT = "default"
-    # generated files will be accessible there
-    BUILD_DIR = "__b"
-    BUILD_DIR_DYN = "dyn"
-    # directory where build files are served to the client
-    PRODUCTION_BUILD_DIR = "sites"
-    # directory used for files needed temporarily (e.g. for compiling other files)
-    DEV_BUILD_DIR = "dev_build"
-
-    TPL_RESOURCE = '_t'
-
-    ERRNUM_BRIDGE_ERRBACK = 0  # FIXME
-    ERRNUM_LIBERVIA = 0  # FIXME
-
-    # Security limit for Libervia (get/set params)
-    SECURITY_LIMIT = 5
-
-    # Security limit for Libervia server_side
-    SERVER_SECURITY_LIMIT = constants.Const.NO_SECURITY_LIMIT
-
-    # keys for cache values we can get from browser
-    ALLOWED_ENTITY_DATA = {"avatar", "nick"}
-
-    STATIC_RSM_MAX_LIMIT = 100
-    STATIC_RSM_MAX_DEFAULT = 10
-    STATIC_RSM_MAX_COMMENTS_DEFAULT = 10
-
-    ## Libervia pages ##
-    PAGES_META_FILE = "page_meta.py"
-    PAGES_BROWSER_DIR = "_browser"
-    PAGES_BROWSER_META_FILE = "browser_meta.json"
-    PAGES_ACCESS_NONE = (
-        "none"
-    )  #  no access to this page (using its path will return a 404 error)
-    PAGES_ACCESS_PUBLIC = "public"
-    PAGES_ACCESS_PROFILE = (
-        "profile"
-    )  # a session with an existing profile must be started
-    PAGES_ACCESS_ADMIN = "admin"  #  only profiles set in admins_list can access the page
-    PAGES_ACCESS_ALL = (
-        PAGES_ACCESS_NONE,
-        PAGES_ACCESS_PUBLIC,
-        PAGES_ACCESS_PROFILE,
-        PAGES_ACCESS_ADMIN,
-    )
-    # names of the page to use for menu
-    DEFAULT_MENU = [
-        "login",
-        "chat",
-        "blog",
-        "forums",
-        "photos",
-        "files",
-        "calendar",
-        "events",
-        "lists",
-        "merge-requests",
-        "calls"
-        # XXX: app is not available anymore since removal of pyjamas code with Python 3
-        #    port. It should come back at a later point with an alternative (Brython
-        #    probably).
-    ]
-
-    ## Session flags ##
-    FLAG_CONFIRM = "CONFIRM"
-
-    ## Data post ##
-    POST_NO_CONFIRM = "POST_NO_CONFIRM"
-
-    ## HTTP methods ##
-    HTTP_METHOD_GET = b"GET"
-    HTTP_METHOD_POST = b"POST"
-
-    ## HTTP codes ##
-    HTTP_SEE_OTHER = 303
-    HTTP_NOT_MODIFIED = 304
-    HTTP_BAD_REQUEST = 400
-    HTTP_UNAUTHORIZED = 401
-    HTTP_FORBIDDEN = 403
-    HTTP_NOT_FOUND = 404
-    HTTP_INTERNAL_ERROR = 500
-    HTTP_PROXY_ERROR = 502
-    HTTP_SERVICE_UNAVAILABLE = 503
-
-    ## HTTP HEADERS ##
-    H_FORWARDED = "Forwarded"
-    H_X_FORWARDED_FOR = "X-Forwarded-For"
-    H_X_FORWARDED_HOST = "X-Forwarded-Host"
-    H_X_FORWARDED_PROTO = "X-Forwarded-Proto"
-
-
-    ## Cache ##
-    CACHE_PUBSUB = 0
-
-    ## Date/Time ##
-    HTTP_DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
-    HTTP_MONTH = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
-                  "Nov", "Dec")
--- a/libervia/server/html_tools.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,36 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-def sanitize_html(text):
-    """Sanitize HTML by escaping everything"""
-    # this code comes from official python wiki: http://wiki.python.org/moin/EscapingHtml
-    html_escape_table = {
-        "&": "&amp;",
-        '"': "&quot;",
-        "'": "&apos;",
-        ">": "&gt;",
-        "<": "&lt;",
-    }
-
-    return "".join(html_escape_table.get(c, c) for c in text)
-
-
-def convert_new_lines_to_xhtml(text):
-    return text.replace("\n", "<br/>")
--- a/libervia/server/launcher.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,31 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Script launching Libervia server"""
-
-from sat.core import launcher
-from libervia.server.constants import Const as C
-
-
-class Launcher(launcher.Launcher):
-    APP_NAME=C.APP_NAME
-    APP_NAME_FILE=C.APP_NAME_FILE
-
-
-if __name__ == '__main__':
-    Launcher.run()
--- a/libervia/server/pages.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1860 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from __future__ import annotations
-import copy
-from functools import reduce
-import hashlib
-import json
-import os.path
-from pathlib import Path
-import time
-import traceback
-from typing import List, Optional, Union
-import urllib.error
-import urllib.parse
-import urllib.request
-import uuid
-
-from twisted.internet import defer
-from twisted.python import failure
-from twisted.python.filepath import FilePath
-from twisted.web import server
-from twisted.web import resource as web_resource
-from twisted.web import util as web_util
-from twisted.words.protocols.jabber import jid
-
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import date_utils
-from sat.tools.common import utils
-from sat.tools.common import data_format
-from sat.tools.utils import as_deferred
-from sat_frontends.bridge.bridge_frontend import BridgeException
-
-from . import session_iface
-from .classes import WebsocketMeta
-from .classes import Script
-from .constants import Const as C
-from .resources import LiberviaRootResource
-from .utils import SubPage, quote
-
-log = getLogger(__name__)
-
-
-class CacheBase(object):
-    def __init__(self):
-        self._created = time.time()
-        self._last_access = self._created
-
-    @property
-    def created(self):
-        return self._created
-
-    @property
-    def last_access(self):
-        return self._last_access
-
-    @last_access.setter
-    def last_access(self, timestamp):
-        self._last_access = timestamp
-
-
-class CachePage(CacheBase):
-    def __init__(self, rendered):
-        super(CachePage, self).__init__()
-        self._created = time.time()
-        self._last_access = self._created
-        self._rendered = rendered
-        self._hash = hashlib.sha256(rendered).hexdigest()
-
-    @property
-    def rendered(self):
-        return self._rendered
-
-    @property
-    def hash(self):
-        return self._hash
-
-
-class CacheURL(CacheBase):
-    def __init__(self, request):
-        super(CacheURL, self).__init__()
-        try:
-            self._data = copy.deepcopy(request.data)
-        except AttributeError:
-            self._data = {}
-        self._template_data = copy.deepcopy(request.template_data)
-        self._prepath = request.prepath[:]
-        self._postpath = request.postpath[:]
-        del self._template_data["csrf_token"]
-
-    def use(self, request):
-        self.last_access = time.time()
-        request.data = copy.deepcopy(self._data)
-        request.template_data.update(copy.deepcopy(self._template_data))
-        request.prepath = self._prepath[:]
-        request.postpath = self._postpath[:]
-
-
-class LiberviaPage(web_resource.Resource):
-    isLeaf = True  #  we handle subpages ourself
-    cache = {}
-    #  Set of tuples (service/node/sub_id) of nodes subscribed for caching
-    # sub_id can be empty string if not handled by service
-    cache_pubsub_sub = set()
-
-    def __init__(
-        self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None,
-        access=None, dynamic=True, parse_url=None, add_breadcrumb=None,
-        prepare_render=None, render=None, template=None, on_data_post=None, on_data=None,
-        url_cache=False, replace_on_conflict=False
-        ):
-        """Initiate LiberviaPage instance
-
-        LiberviaPages are the main resources of Libervia, using easy to set python files
-        The non mandatory arguments are the variables found in page_meta.py
-        @param host(Libervia): the running instance of Libervia
-        @param vhost_root(web_resource.Resource): root resource of the virtual host which
-            handle this page.
-        @param root_dir(Path): absolute file path of the page
-        @param url(unicode): relative URL to the page
-            this URL may not be valid, as pages may require path arguments
-        @param name(unicode, None): if not None, a unique name to identify the page
-            can then be used for e.g. redirection
-            "/" is not allowed in names (as it can be used to construct URL paths)
-        @param redirect(unicode, None): if not None, this page will be redirected.
-            A redirected parameter is used as in self.page_redirect.
-            parse_url will not be skipped
-            using this redirect parameter is called "full redirection"
-            using self.page_redirect is called "partial redirection" (because some
-            rendering method can still be used, e.g. parse_url)
-        @param access(unicode, None): permission needed to access the page
-            None means public access.
-            Pages inherit from parent pages: e.g. if a "settings" page is restricted
-            to admins, and if "settings/blog" is public, it still can only be accessed by
-            admins. See C.PAGES_ACCESS_* for details
-        @param dynamic(bool): if True, activate websocket for bidirectional communication
-        @param parse_url(callable, None): if set it will be called to handle the URL path
-            after this method, the page will be rendered if noting is left in path
-            (request.postpath) else a the request will be transmitted to a subpage
-        @param add_breadcrumb(callable, None): if set, manage the breadcrumb data for this
-            page, otherwise it will be set automatically from page name or label.
-        @param prepare_render(callable, None): if set, will be used to prepare the
-            rendering. That often means gathering data using the bridge
-        @param render(callable, None): if template is not set, this method will be
-            called and what it returns will be rendered.
-            This method is mutually exclusive with template and must return a unicode
-            string.
-        @param template(unicode, None): path to the template to render.
-            This method is mutually exclusive with render
-        @param on_data_post(callable, None): method to call when data is posted
-            None if data post is not handled
-            "continue" if data post is not handled there, and we must not interrupt
-            workflow (i.e. it's handled in "render" method).
-            otherwise, on_data_post can return a string with following value:
-                - C.POST_NO_CONFIRM: confirm flag will not be set
-            on_data_post can raise following exceptions:
-                - exceptions.DataError: value is incorrect, message will be displayed
-                    as a notification
-        @param on_data(callable, None): method to call when dynamic data is sent
-            this method is used with Libervia's websocket mechanism
-        @param url_cache(boolean): if set, result of parse_url is cached (per profile).
-            Useful when costly calls (e.g. network) are done while parsing URL.
-        @param replace_on_conflict(boolean): if True, don't raise ConflictError if a
-            page of this name already exists, but replace it
-        """
-
-        web_resource.Resource.__init__(self)
-        self.host = host
-        self.vhost_root = vhost_root
-        self.root_dir = root_dir
-        self.url = url
-        self.name = name
-        self.label = label
-        self.dyn_data = {}
-        if name is not None:
-            if (name in self.named_pages
-                and not (replace_on_conflict and self.named_pages[name].url == url)):
-                raise exceptions.ConflictError(
-                    _('a Libervia page named "{}" already exists'.format(name)))
-            if "/" in name:
-                raise ValueError(_('"/" is not allowed in page names'))
-            if not name:
-                raise ValueError(_("a page name can't be empty"))
-            self.named_pages[name] = self
-        if access is None:
-            access = C.PAGES_ACCESS_PUBLIC
-        if access not in (
-            C.PAGES_ACCESS_PUBLIC,
-            C.PAGES_ACCESS_PROFILE,
-            C.PAGES_ACCESS_NONE,
-        ):
-            raise NotImplementedError(
-                _("{} access is not implemented yet").format(access)
-            )
-        self.access = access
-        self.dynamic = dynamic
-        if redirect is not None:
-            # only page access and name make sense in case of full redirection
-            # so we check that rendering methods/values are not set
-            if not all(
-                lambda x: x is not None
-                for x in (parse_url, prepare_render, render, template)
-            ):
-                raise ValueError(
-                    _("you can't use full page redirection with other rendering"
-                      "method, check self.page_redirect if you need to use them"))
-            self.redirect = redirect
-        else:
-            self.redirect = None
-        self.parse_url = parse_url
-        self.add_breadcrumb = add_breadcrumb
-        self.prepare_render = prepare_render
-        self.template = template
-        self.render_method = render
-        self.on_data_post = on_data_post
-        self.on_data = on_data
-        self.url_cache = url_cache
-        if access == C.PAGES_ACCESS_NONE:
-            # none pages just return a 404, no further check is needed
-            return
-        if template is not None and render is not None:
-            log.error(_("render and template methods can't be used at the same time"))
-
-        # if not None, next rendering will be cached
-        #  it must then contain a list of the the keys to use (without the page instance)
-        # e.g. [C.SERVICE_PROFILE, "pubsub", server@example.tld, pubsub_node]
-        self._do_cache = None
-
-    def __str__(self):
-        return "LiberviaPage {name} at {url} (vhost: {vhost_root})".format(
-            name=self.name or "<anonymous>", url=self.url, vhost_root=self.vhost_root)
-
-    @property
-    def named_pages(self):
-        return self.vhost_root.named_pages
-
-    @property
-    def uri_callbacks(self):
-        return self.vhost_root.uri_callbacks
-
-    @property
-    def pages_redirects(self):
-        return self.vhost_root.pages_redirects
-
-    @property
-    def cached_urls(self):
-        return self.vhost_root.cached_urls
-
-    @property
-    def main_menu(self):
-        return self.vhost_root.main_menu
-
-    @property
-    def default_theme(self):
-        return self.vhost_root.default_theme
-
-
-    @property
-    def site_themes(self):
-        return self.vhost_root.site_themes
-
-    @staticmethod
-    def create_page(host, meta_path, vhost_root, url_elts, replace_on_conflict=False):
-        """Create a LiberviaPage instance
-
-        @param meta_path(Path): path to the page_meta.py file
-        @param vhost_root(resource.Resource): root resource of the virtual host
-        @param url_elts(list[unicode]): list of path element from root site to this page
-        @param replace_on_conflict(bool): same as for [LiberviaPage]
-        @return (tuple[dict, LiberviaPage]): tuple with:
-            - page_data: dict containing data of the page
-            - libervia_page: created resource
-        """
-        dir_path = meta_path.parent
-        page_data = {"__name__": ".".join(["page"] + url_elts)}
-        # we don't want to force the presence of __init__.py
-        # so we use execfile instead of import.
-        # TODO: when moved to Python 3, __init__.py is not mandatory anymore
-        #       so we can switch to import
-        exec(compile(open(meta_path, "rb").read(), meta_path, 'exec'), page_data)
-        return page_data, LiberviaPage(
-            host=host,
-            vhost_root=vhost_root,
-            root_dir=dir_path,
-            url="/" + "/".join(url_elts),
-            name=page_data.get("name"),
-            label=page_data.get("label"),
-            redirect=page_data.get("redirect"),
-            access=page_data.get("access"),
-            dynamic=page_data.get("dynamic", True),
-            parse_url=page_data.get("parse_url"),
-            add_breadcrumb=page_data.get("add_breadcrumb"),
-            prepare_render=page_data.get("prepare_render"),
-            render=page_data.get("render"),
-            template=page_data.get("template"),
-            on_data_post=page_data.get("on_data_post"),
-            on_data=page_data.get("on_data"),
-            url_cache=page_data.get("url_cache", False),
-            replace_on_conflict=replace_on_conflict
-        )
-
-    @staticmethod
-    def create_browser_data(
-        vhost_root,
-        resource: Optional[LiberviaPage],
-        browser_path: Path,
-        path_elts: Optional[List[str]],
-        engine: str = "brython"
-    ) -> None:
-        """create and store data for browser dynamic code"""
-        dyn_data = {
-            "path": browser_path,
-            "url_hash": (
-                hashlib.sha256('/'.join(path_elts).encode()).hexdigest()
-                if path_elts is not None else None
-            ),
-        }
-        browser_meta_path = browser_path / C.PAGES_BROWSER_META_FILE
-        if browser_meta_path.is_file():
-            with browser_meta_path.open() as f:
-                browser_meta = json.load(f)
-            utils.recursive_update(vhost_root.browser_modules, browser_meta)
-            if resource is not None:
-                utils.recursive_update(resource.dyn_data, browser_meta)
-
-        init_path = browser_path / '__init__.py'
-        if init_path.is_file():
-            vhost_root.browser_modules.setdefault(
-                engine, []).append(dyn_data)
-            if resource is not None:
-                resource.dyn_data[engine] = dyn_data
-        elif path_elts is None:
-            try:
-                next(browser_path.glob('*.py'))
-            except StopIteration:
-                # no python file, nothing for Brython
-                pass
-            else:
-                vhost_root.browser_modules.setdefault(
-                    engine, []).append(dyn_data)
-
-
-    @classmethod
-    def import_pages(cls, host, vhost_root, root_path=None, _parent=None, _path=None,
-        _extra_pages=False):
-        """Recursively import Libervia pages
-
-        @param host(Libervia): Libervia instance
-        @param vhost_root(LiberviaRootResource): root of this VirtualHost
-        @param root_path(Path, None): use this root path instead of vhost_root's one
-            Used to add default site pages to external sites
-        @param _parent(Resource, None): _parent page. Do not set yourself, this is for
-            internal use only
-        @param _path(list(unicode), None): current path. Do not set yourself, this is for
-            internal use only
-        @param _extra_pages(boolean): set to True when extra pages are used (i.e.
-            root_path is set). Do not set yourself, this is for internal use only
-        """
-        if _path is None:
-            _path = []
-        if _parent is None:
-            if root_path is None:
-                root_dir = vhost_root.site_path / C.PAGES_DIR
-            else:
-                root_dir = root_path / C.PAGES_DIR
-                _extra_pages = True
-            _parent = vhost_root
-            root_browser_path = root_dir / C.PAGES_BROWSER_DIR
-            if root_browser_path.is_dir():
-                cls.create_browser_data(vhost_root, None, root_browser_path, None)
-        else:
-            root_dir = _parent.root_dir
-
-        for d in os.listdir(root_dir):
-            dir_path = root_dir / d
-            if not dir_path.is_dir():
-                continue
-            if _extra_pages and d in _parent.children:
-                log.debug(_("[{host_name}] {path} is already present, ignoring it")
-                    .format(host_name=vhost_root.host_name, path='/'.join(_path+[d])))
-                continue
-            meta_path = dir_path / C.PAGES_META_FILE
-            if meta_path.is_file():
-                new_path = _path + [d]
-                try:
-                    page_data, resource = cls.create_page(
-                        host, meta_path, vhost_root, new_path)
-                except exceptions.ConflictError as e:
-                    if _extra_pages:
-                        # extra pages are discarded if there is already an existing page
-                        continue
-                    else:
-                        raise e
-                _parent.putChild(str(d).encode(), resource)
-                log_msg = ("[{host_name}] Added /{path} page".format(
-                    host_name=vhost_root.host_name,
-                    path="[…]/".join(new_path)))
-                if _extra_pages:
-                    log.debug(log_msg)
-                else:
-                    log.info(log_msg)
-                if "uri_handlers" in page_data:
-                    if not isinstance(page_data, dict):
-                        log.error(_("uri_handlers must be a dict"))
-                    else:
-                        for uri_tuple, cb_name in page_data["uri_handlers"].items():
-                            if len(uri_tuple) != 2 or not isinstance(cb_name, str):
-                                log.error(_("invalid uri_tuple"))
-                                continue
-                            if not _extra_pages:
-                                log.info(_("setting {}/{} URIs handler")
-                                         .format(*uri_tuple))
-                            try:
-                                cb = page_data[cb_name]
-                            except KeyError:
-                                log.error(_("missing {name} method to handle {1}/{2}")
-                                          .format(name=cb_name, *uri_tuple))
-                                continue
-                            else:
-                                resource.register_uri(uri_tuple, cb)
-
-                LiberviaPage.import_pages(
-                    host, vhost_root, _parent=resource, _path=new_path,
-                    _extra_pages=_extra_pages)
-                # now we check if there is some code for browser
-                browser_path = dir_path / C.PAGES_BROWSER_DIR
-                if browser_path.is_dir():
-                    cls.create_browser_data(vhost_root, resource, browser_path, new_path)
-
-    @classmethod
-    def on_file_change(
-        cls,
-        host,
-        file_path: FilePath,
-        flags: List[str],
-        site_root: LiberviaRootResource,
-        site_path: Path
-    ) -> None:
-        """Method triggered by file_watcher when something is changed in files
-
-        This method is used in dev mode to reload pages when needed
-        @param file_path: path of the file which triggered the event
-        @param flags: human readable flags of the event (from
-            internet.inotify)
-        @param site_root: root of the site
-        @param site_path: absolute path of the site
-        """
-        if flags == ['create']:
-            return
-        path = Path(file_path.path.decode())
-        base_name = path.name
-        if base_name != "page_meta.py":
-            # we only handle libervia pages
-            return
-
-        log.debug("{flags} event(s) received for {file_path}".format(
-            flags=", ".join(flags), file_path=file_path))
-
-        dir_path = path.parent
-
-        if dir_path == site_path:
-            return
-
-        if not site_path in dir_path.parents:
-            raise exceptions.InternalError("watched file should be in a subdirectory of site path")
-
-        path_elts = list(dir_path.relative_to(site_path).parts)
-
-        if path_elts[0] == C.PAGES_DIR:
-            # a page has been modified
-            del path_elts[0]
-            if not path_elts:
-                # we need at least one element to parse
-                return
-            # we retrieve page by starting from site root and finding each path element
-            parent = page = site_root
-            new_page = False
-            for idx, child_name in enumerate(path_elts):
-                child_name = child_name.encode()
-                try:
-                    try:
-                        page = page.original.children[child_name]
-                    except AttributeError:
-                        page = page.children[child_name]
-                except KeyError:
-                    if idx != len(path_elts)-1:
-                        # a page has been created in a subdir when one or more
-                        # page_meta.py are missing on the way
-                        log.warning(_("Can't create a page at {path}, missing parents")
-                                    .format(path=path))
-                        return
-                    new_page = True
-                else:
-                    if idx<len(path_elts)-1:
-                        try:
-                            parent = page.original
-                        except AttributeError:
-                            parent = page
-
-            try:
-                # we (re)create a page with the new/modified code
-                __, resource = cls.create_page(host, path, site_root, path_elts,
-                                              replace_on_conflict=True)
-                if not new_page:
-                    try:
-                        resource.children = page.original.children
-                    except AttributeError:
-                        # FIXME: this .original handling madness is due to EncodingResourceWrapper
-                        #        EncodingResourceWrapper should probably be removed
-                        resource.children = page.children
-            except Exception as e:
-                log.warning(_("Can't create page: {reason}").format(reason=e))
-            else:
-                url_elt = path_elts[-1].encode()
-                if not new_page:
-                    # the page was already existing, we remove it
-                    del parent.children[url_elt]
-                # we can now add the new page
-                parent.putChild(url_elt, resource)
-
-                # is there any browser data to create?
-                browser_path = resource.root_dir / C.PAGES_BROWSER_DIR
-                if browser_path.is_dir():
-                    cls.create_browser_data(
-                        resource.vhost_root,
-                        resource,
-                        browser_path,
-                        resource.url.split('/')
-                    )
-
-                if new_page:
-                    log.info(_("{page} created").format(page=resource))
-                else:
-                    log.info(_("{page} reloaded").format(page=resource))
-
-    def check_csrf(self, request):
-        session = self.host.get_session_data(
-            request, session_iface.IWebSession
-        )
-        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:
-                given_csrf = self.get_posted_data(request, "csrf_token")
-            except KeyError:
-                pass
-        if given_csrf is None or given_csrf != csrf_token:
-            log.warning(
-                _("invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format(
-                    url=request.uri, ip=request.getClientIP()
-                )
-            )
-            self.page_error(request, C.HTTP_FORBIDDEN)
-
-    def expose_to_scripts(
-        self,
-        request: server.Request,
-        **kwargs: str
-    ) -> None:
-        """Make a local variable available to page script as a global variable
-
-        No check is done for conflicting name, use this carefully
-        """
-        template_data = request.template_data
-        scripts = template_data.setdefault("scripts", utils.OrderedSet())
-        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};"))
-
-    def register_uri(self, uri_tuple, get_uri_cb):
-        """Register a URI handler
-
-        @param uri_tuple(tuple[unicode, unicode]): type or URIs handler
-            type/subtype as returned by tools/common/parse_xmpp_uri
-            or type/None to handle all subtypes
-        @param get_uri_cb(callable): method which take uri_data dict as only argument
-            and return absolute path with correct arguments or None if the page
-            can't handle this URL
-        """
-        if uri_tuple in self.uri_callbacks:
-            log.info(_("{}/{} URIs are already handled, replacing by the new handler")
-                .format( *uri_tuple))
-        self.uri_callbacks[uri_tuple] = (self, get_uri_cb)
-
-    def config_get(self, key, default=None, value_type=None):
-        return self.host.config_get(self.vhost_root, key=key, default=default,
-                                   value_type=value_type)
-
-    def get_build_path(self, session_data):
-        return session_data.cache_dir + self.vhost.site_name
-
-    def get_page_by_name(self, name):
-        return self.vhost_root.get_page_by_name(name)
-
-    def get_page_path_from_uri(self, uri):
-        return self.vhost_root.get_page_path_from_uri(uri)
-
-    def get_page_redirect_url(self, request, page_name="login", url=None):
-        """generate URL for a page with redirect_url parameter set
-
-        mainly used for login page with redirection to current page
-        @param request(server.Request): current HTTP request
-        @param page_name(unicode): name of the page to go
-        @param url(None, unicode): url to redirect to
-            None to use request path (i.e. current page)
-        @return (unicode): URL to use
-        """
-        return "{root_url}?redirect_url={redirect_url}".format(
-            root_url=self.get_page_by_name(page_name).url,
-            redirect_url=urllib.parse.quote_plus(request.uri)
-            if url is None
-            else url.encode("utf-8"),
-        )
-
-    def get_url(self, *args: str, **kwargs: str) -> str:
-        """retrieve URL of the page set arguments
-
-        @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]
-
-        if self.name is not None and self.name in self.pages_redirects:
-            #  we check for redirection
-            redirect_data = self.pages_redirects[self.name]
-            args_hash = tuple(args)
-            for limit in 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)
-                    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)
-
-        if kwargs:
-            encoded = urllib.parse.urlencode(
-                {k: v for k, v in kwargs.items()}
-            )
-            url +=  f"?{encoded}"
-
-        return self.host.check_redirection(
-            self.vhost_root,
-            url
-        )
-
-    def get_current_url(self, request):
-        """retrieve URL used to access this page
-
-        @return(unicode): current URL
-        """
-        # we get url in the following way (splitting request.path instead of using
-        # request.prepath) because request.prepath may have been modified by
-        # redirection (if redirection args have been specified), while path reflect
-        # the real request
-
-        # we ignore empty path elements (i.e. double '/' or '/' at the end)
-        path_elts = [p for p in request.path.decode('utf-8').split("/") if p]
-
-        if request.postpath:
-            if not request.postpath[-1]:
-                #  we remove trailing slash
-                request.postpath = request.postpath[:-1]
-            if request.postpath:
-                #  get_sub_page_url must return subpage from the point where
-                # the it is called, so we have to remove remanining
-                # path elements
-                path_elts = path_elts[: -len(request.postpath)]
-
-        return "/" + "/".join(path_elts)
-
-    def get_param_url(self, request, **kwargs):
-        """use URL of current request but modify the parameters in query part
-
-        **kwargs(dict[str, unicode]): argument to use as query parameters
-        @return (unicode): constructed URL
-        """
-        current_url = self.get_current_url(request)
-        if kwargs:
-            encoded = urllib.parse.urlencode(
-                {k: v for k, v in kwargs.items()}
-            )
-            current_url = current_url + "?" + encoded
-        return current_url
-
-    def get_sub_page_by_name(self, subpage_name, parent=None):
-        """retrieve a subpage and its path using its name
-
-        @param subpage_name(unicode): name of the sub page
-            it must be a direct children of parent page
-        @param parent(LiberviaPage, None): parent page
-            None to use current page
-        @return (tuple[str, LiberviaPage]): page subpath and instance
-        @raise exceptions.NotFound: no page has been found
-        """
-        if parent is None:
-            parent = self
-        for path, child in parent.children.items():
-            try:
-                child_name = child.name
-            except AttributeError:
-                #  LiberviaPages have a name, but maybe this is an other Resource
-                continue
-            if child_name == subpage_name:
-                return path.decode('utf-8'), child
-        raise exceptions.NotFound(
-            _("requested sub page has not been found ({subpage_name})").format(
-            subpage_name=subpage_name))
-
-    def get_sub_page_url(self, request, page_name, *args):
-        """retrieve a page in direct children and build its URL according to request
-
-        request's current path is used as base (at current parsing point,
-        i.e. it's more prepath than path).
-        Requested page is checked in children and an absolute URL is then built
-        by the resulting combination.
-        This method is useful to construct absolute URLs for children instead of
-        using relative path, which may not work in subpages, and are linked to the
-        names of directories (i.e. relative URL will break if subdirectory is renamed
-        while get_sub_page_url won't as long as page_name is consistent).
-        Also, request.path is used, keeping real path used by user,
-        and potential redirections.
-        @param request(server.Request): current HTTP request
-        @param page_name(unicode): name of the page to retrieve
-            it must be a direct children of current page
-        @param *args(list[unicode]): arguments to add as path elements
-            if an arg is None, it will be ignored
-        @return (unicode): absolute URL to the sub page
-        """
-        current_url = self.get_current_url(request)
-        path, child = self.get_sub_page_by_name(page_name)
-        return os.path.join(
-            "/", current_url, path, *[quote(a) for a in args if a is not None]
-        )
-
-    def get_url_by_names(self, named_path):
-        """Retrieve URL from pages names and arguments
-
-        @param named_path(list[tuple[unicode, list[unicode]]]): path to the page as a list
-            of tuples of 2 items:
-                - first item is page name
-                - second item is list of path arguments of this page
-        @return (unicode): URL to the requested page with given path arguments
-        @raise exceptions.NotFound: one of the page was not found
-        """
-        current_page = None
-        path = []
-        for page_name, page_args in named_path:
-            if current_page is None:
-                current_page = self.get_page_by_name(page_name)
-                path.append(current_page.get_url(*page_args))
-            else:
-                sub_path, current_page = self.get_sub_page_by_name(
-                    page_name, parent=current_page
-                )
-                path.append(sub_path)
-                if page_args:
-                    path.extend([quote(a) for a in page_args])
-        return self.host.check_redirection(self.vhost_root, "/".join(path))
-
-    def get_url_by_path(self, *args):
-        """Generate URL by path
-
-        this method as a similar effect as get_url_by_names, but it is more readable
-        by using SubPage to get pages instead of using tuples
-        @param *args: path element:
-            - if unicode, will be used as argument
-            - if util.SubPage instance, must be the name of a subpage
-        @return (unicode): generated path
-        """
-        args = list(args)
-        if not args:
-            raise ValueError("You must specify path elements")
-        # root page is the one needed to construct the base of the URL
-        # if first arg is not a SubPage instance, we use current page
-        if not isinstance(args[0], SubPage):
-            root = self
-        else:
-            root = self.get_page_by_name(args.pop(0))
-        # we keep track of current page to check subpage
-        current_page = root
-        url_elts = []
-        arguments = []
-        while True:
-            while args and not isinstance(args[0], SubPage):
-                arguments.append(quote(args.pop(0)))
-            if not url_elts:
-                url_elts.append(root.get_url(*arguments))
-            else:
-                url_elts.extend(arguments)
-            if not args:
-                break
-            else:
-                path, current_page = current_page.get_sub_page_by_name(args.pop(0))
-                arguments = [path]
-        return self.host.check_redirection(self.vhost_root, "/".join(url_elts))
-
-    def getChildWithDefault(self, path, request):
-        # we handle children ourselves
-        raise exceptions.InternalError(
-            "this method should not be used with LiberviaPage"
-        )
-
-    def next_path(self, request):
-        """get next URL path segment, and update request accordingly
-
-        will move first segment of postpath in prepath
-        @param request(server.Request): current HTTP request
-        @return (unicode): unquoted segment
-        @raise IndexError: there is no segment left
-        """
-        pathElement = request.postpath.pop(0)
-        request.prepath.append(pathElement)
-        return urllib.parse.unquote(pathElement.decode('utf-8'))
-
-    def _filter_path_value(self, value, handler, name, request):
-        """Modify a path value according to handler (see [get_path_args])"""
-        if handler in ("@", "@jid") and value == "@":
-            value = None
-
-        if handler in ("", "@"):
-            if value is None:
-                return ""
-        elif handler in ("jid", "@jid"):
-            if value:
-                try:
-                    return jid.JID(value)
-                except (RuntimeError, jid.InvalidFormat):
-                    log.warning(_("invalid jid argument: {value}").format(value=value))
-                    self.page_error(request, C.HTTP_BAD_REQUEST)
-            else:
-                return ""
-        else:
-            return handler(self, value, name, request)
-
-        return value
-
-    def get_path_args(self, request, names, min_args=0, **kwargs):
-        """get several path arguments at once
-
-        Arguments will be put in request data.
-        Missing arguments will have None value
-        @param names(list[unicode]): list of arguments to get
-        @param min_args(int): if less than min_args are found, PageError is used with
-            C.HTTP_BAD_REQUEST
-            Use 0 to ignore
-        @param **kwargs: special value or optional callback to use for arguments
-            names of the arguments must correspond to those in names
-            special values may be:
-                - '': use empty string instead of None when no value is specified
-                - '@': if value of argument is empty or '@', empty string will be used
-                - 'jid': value must be converted to jid.JID if it exists, else empty
-                    string is used
-                - '@jid': if value of arguments is empty or '@', empty string will be
-                    used, else it will be converted to jid
-        """
-        data = self.get_r_data(request)
-
-        for idx, name in enumerate(names):
-            if name[0] == "*":
-                value = data[name[1:]] = []
-                while True:
-                    try:
-                        value.append(self.next_path(request))
-                    except IndexError:
-                        idx -= 1
-                        break
-                    else:
-                        idx += 1
-            else:
-                try:
-                    value = data[name] = self.next_path(request)
-                except IndexError:
-                    data[name] = None
-                    idx -= 1
-                    break
-
-        values_count = idx + 1
-        if values_count < min_args:
-            log.warning(_("Missing arguments in URL (got {count}, expected at least "
-                          "{min_args})").format(count=values_count, min_args=min_args))
-            self.page_error(request, C.HTTP_BAD_REQUEST)
-
-        for name in names[values_count:]:
-            data[name] = None
-
-        for name, handler in kwargs.items():
-            if name[0] == "*":
-                data[name] = [
-                    self._filter_path_value(v, handler, name, request) for v in data[name]
-                ]
-            else:
-                data[name] = self._filter_path_value(data[name], handler, name, request)
-
-    ## Pagination/Filtering ##
-
-    def get_pubsub_extra(self, request, page_max=10, params=None, extra=None,
-        order_by=C.ORDER_BY_CREATION):
-        """Set extra dict to retrieve PubSub items corresponding to URL parameters
-
-        Following parameters are used:
-            - after: set rsm_after with ID of item
-            - before: set rsm_before with ID of item
-        @param request(server.Request): current HTTP request
-        @param page_max(int): required number of items per page
-        @param params(None, dict[unicode, list[unicode]]): params as returned by
-            self.get_all_posted_data.
-            None to parse URL automatically
-        @param extra(None, dict): extra dict to use, or None to use a new one
-        @param order_by(unicode, None): key to order by
-            None to not specify order
-        @return (dict): fill extra data
-        """
-        if params is None:
-            params = self.get_all_posted_data(request, multiple=False)
-        if extra is None:
-            extra = {}
-        else:
-            assert not {"rsm_max", "rsm_after", "rsm_before",
-                        C.KEY_ORDER_BY}.intersection(list(extra.keys()))
-        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:
-            extra['rsm_after'] = params['after']
-        elif 'before' in params:
-            extra['rsm_before'] = params['before']
-        else:
-            # RSM returns list in order (oldest first), but we want most recent first
-            # so we start by the end
-            extra['rsm_before'] = ""
-        return extra
-
-    def set_pagination(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: current HTTP request
-        @param pubsub_data: pubsub metadata
-        """
-        template_data = request.template_data
-        extra = {}
-        try:
-            rsm = pubsub_data["rsm"]
-            last_id = rsm["last"]
-        except KeyError:
-            # no pagination available
-            return
-
-        # if we have a search query, we must keep it
-        search = self.get_posted_data(request, 'search', raise_on_missing=False)
-        if search is not None:
-            extra['search'] = search.strip()
-
-        # same for page_max
-        page_max = self.get_posted_data(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
-            # as we can't know if we are on the first page or not.
-            first_id = rsm["first"]
-            template_data['previous_page_url'] = self.get_param_url(
-                request, before=first_id, **extra)
-        if not pubsub_data["complete"]:
-            # we also show the page next button if complete is None because we
-            # can't know where we are in the feed in this case.
-            template_data['next_page_url'] = self.get_param_url(
-                request, after=last_id, **extra)
-
-
-    ## Cache handling ##
-
-    def _set_cache_headers(self, request, cache):
-        """Set ETag and Last-Modified HTTP headers, used for caching"""
-        request.setHeader("ETag", cache.hash)
-        last_modified = self.host.get_http_date(cache.created)
-        request.setHeader("Last-Modified", last_modified)
-
-    def _check_cache_headers(self, request, cache):
-        """Check if a cache condition is set on the request
-
-        if condition is valid, C.HTTP_NOT_MODIFIED is returned
-        """
-        etag_match = request.getHeader("If-None-Match")
-        if etag_match is not None:
-            if cache.hash == etag_match:
-                self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True)
-        else:
-            modified_match = request.getHeader("If-Modified-Since")
-            if modified_match is not None:
-                modified = date_utils.date_parse(modified_match)
-                if modified >= int(cache.created):
-                    self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True)
-
-    def check_cache_subscribe_cb(self, sub_id, service, node):
-        self.cache_pubsub_sub.add((service, node, sub_id))
-
-    def check_cache_subscribe_eb(self, failure_, service, node):
-        log.warning(_("Can't subscribe to node: {msg}").format(msg=failure_))
-        # FIXME: cache must be marked as unusable here
-
-    def ps_node_watch_add_eb(self, failure_, service, node):
-        log.warning(_("Can't add node watched: {msg}").format(msg=failure_))
-
-    def use_cache(self, request: server.Request) -> bool:
-        """Indicate if the cache should be used
-
-        test request header to see if it is requested to skip the cache
-        @return: True if cache should be used
-        """
-        return request.getHeader('cache-control') != 'no-cache'
-
-    def check_cache(self, request, cache_type, **kwargs):
-        """check if a page is in cache and return cached version if suitable
-
-        this method may perform extra operation to handle cache (e.g. subscribing to a
-        pubsub node)
-        @param request(server.Request): current HTTP request
-        @param cache_type(int): on of C.CACHE_* const.
-        @param **kwargs: args according to cache_type:
-            C.CACHE_PUBSUB:
-                service: pubsub service
-                node: pubsub node
-                short: short name of feature (needed if node is empty to find namespace)
-
-        """
-        if request.postpath:
-            #  we are not on the final page, no need to go further
-            return
-
-        if request.uri != request.path:
-            # we don't cache page with query arguments as there can be a lot of variants
-            # influencing page results (e.g. search terms)
-            log.debug("ignoring cache due to query arguments")
-
-        no_cache = not self.use_cache(request)
-
-        profile = self.get_profile(request) or C.SERVICE_PROFILE
-
-        if cache_type == C.CACHE_PUBSUB:
-            service, node = kwargs["service"], kwargs["node"]
-            if not node:
-                try:
-                    short = kwargs["short"]
-                    node = self.host.ns_map[short]
-                except KeyError:
-                    log.warning(_('Can\'t use cache for empty node without namespace '
-                                  'set, please ensure to set "short" and that it is '
-                                  'registered'))
-                    return
-            if profile != C.SERVICE_PROFILE:
-                #  only service profile is cached for now
-                return
-            session_data = self.host.get_session_data(request, session_iface.IWebSession)
-            locale = session_data.locale
-            if locale == C.DEFAULT_LOCALE:
-                # no need to duplicate cache here
-                locale = None
-            try:
-                cache = (self.cache[profile][cache_type][service][node]
-                         [self.vhost_root][request.uri][locale][self])
-            except KeyError:
-                # no cache yet, let's subscribe to the pubsub node
-                d1 = self.host.bridge_call(
-                    "ps_subscribe", service.full(), node, "", profile
-                )
-                d1.addCallback(self.check_cache_subscribe_cb, service, node)
-                d1.addErrback(self.check_cache_subscribe_eb, service, node)
-                d2 = self.host.bridge_call("ps_node_watch_add", service.full(), node, profile)
-                d2.addErrback(self.ps_node_watch_add_eb, service, node)
-                self._do_cache = [self, profile, cache_type, service, node,
-                                  self.vhost_root, request.uri, locale]
-                #  we don't return the Deferreds as it is not needed to wait for
-                # the subscription to continue with page rendering
-                return
-            else:
-                if no_cache:
-                    del (self.cache[profile][cache_type][service][node]
-                         [self.vhost_root][request.uri][locale][self])
-                    log.debug(f"cache removed for {self}")
-                    return
-
-        else:
-            raise exceptions.InternalError("Unknown cache_type")
-        log.debug("using cache for {page}".format(page=self))
-        cache.last_access = time.time()
-        self._set_cache_headers(request, cache)
-        self._check_cache_headers(request, cache)
-        request.write(cache.rendered)
-        request.finish()
-        raise failure.Failure(exceptions.CancelError("cache is used"))
-
-    def _cache_url(self, request, profile):
-        self.cached_urls.setdefault(profile, {})[request.uri] = CacheURL(request)
-
-    @classmethod
-    def on_node_event(cls, host, service, node, event_type, items, profile):
-        """Invalidate cache for all pages linked to this node"""
-        try:
-            cache = cls.cache[profile][C.CACHE_PUBSUB][jid.JID(service)][node]
-        except KeyError:
-            log.info(_(
-                "Removing subscription for {service}/{node}: "
-                "the page is not cached").format(service=service, node=node))
-            d1 = host.bridge_call("ps_unsubscribe", service, node, profile)
-            d1.addErrback(
-                lambda failure_: log.warning(
-                    _("Can't unsubscribe from {service}/{node}: {msg}").format(
-                        service=service, node=node, msg=failure_)))
-            d2 = host.bridge_call("ps_node_watch_add", service, node, profile)
-            # TODO: check why the page is not in cache, remove subscription?
-            d2.addErrback(
-                lambda failure_: log.warning(
-                    _("Can't remove watch for {service}/{node}: {msg}").format(
-                        service=service, node=node, msg=failure_)))
-        else:
-            cache.clear()
-
-    # identities
-
-    async def fill_missing_identities(
-        self,
-        request: server.Request,
-        entities: List[Union[str, jid.JID, None]],
-    ) -> None:
-        """Check if all entities have an identity cache, get missing ones from backend
-
-        @param request: request with a plugged profile
-        @param entities: entities to check, None or empty strings will be filtered
-        """
-        entities = {str(e) for e in entities if e}
-        profile = self.get_profile(request) or C.SERVICE_PROFILE
-        identities = self.host.get_session_data(
-            request,
-            session_iface.IWebSession
-        ).identities
-        for e in entities:
-            if e not in identities:
-                id_raw = await self.host.bridge_call(
-                    'identity_get', e, [], True, profile)
-                identities[e] = data_format.deserialise(id_raw)
-
-    # signals, server => browser communication
-
-    def delegate_to_resource(self, request, resource):
-        """continue workflow with Twisted Resource"""
-        buf = resource.render(request)
-        if buf == server.NOT_DONE_YET:
-            pass
-        else:
-            request.write(buf)
-            request.finish()
-        raise failure.Failure(exceptions.CancelError("resource delegation"))
-
-    def http_redirect(self, request, url):
-        """redirect to an URL using HTTP redirection
-
-        @param request(server.Request): current HTTP request
-        @param url(unicode): url to redirect to
-        """
-        web_util.redirectTo(url.encode("utf-8"), request)
-        request.finish()
-        raise failure.Failure(exceptions.CancelError("HTTP redirection is used"))
-
-    def redirect_or_continue(self, request, redirect_arg="redirect_url"):
-        """Helper method to redirect a page to an url given as arg
-
-        if the arg is not present, the page will continue normal workflow
-        @param request(server.Request): current HTTP request
-        @param redirect_arg(unicode): argument to use to get redirection URL
-        @interrupt: redirect the page to requested URL
-        @interrupt page_error(C.HTTP_BAD_REQUEST): empty or non local URL is used
-        """
-        redirect_arg = redirect_arg.encode('utf-8')
-        try:
-            url = request.args[redirect_arg][0].decode('utf-8')
-        except (KeyError, IndexError):
-            pass
-        else:
-            #  a redirection is requested
-            if not url or url[0] != "/":
-                # we only want local urls
-                self.page_error(request, C.HTTP_BAD_REQUEST)
-            else:
-                self.http_redirect(request, url)
-
-    def page_redirect(self, page_path, request, skip_parse_url=True, path_args=None):
-        """redirect a page to a named page
-
-        the workflow will continue with the workflow of the named page,
-        skipping named page's parse_url method if it exist.
-        If you want to do a HTTP redirection, use http_redirect
-        @param page_path(unicode): path to page (elements are separated by "/"):
-            if path starts with a "/":
-                path is a full path starting from root
-            else:
-                - first element is name as registered in name variable
-                - following element are subpages path
-            e.g.: "blog" redirect to page named "blog"
-                  "blog/atom.xml" redirect to atom.xml subpage of "blog"
-                  "/common/blog/atom.xml" redirect to the page at the given full path
-        @param request(server.Request): current HTTP request
-        @param skip_parse_url(bool): if True, parse_url method on redirect page will be
-            skipped
-        @param path_args(list[unicode], None): path arguments to use in redirected page
-        @raise KeyError: there is no known page with this name
-        """
-        # FIXME: render non LiberviaPage resources
-        path = page_path.rstrip("/").split("/")
-        if not path[0]:
-            redirect_page = self.vhost_root
-        else:
-            redirect_page = self.named_pages[path[0]]
-
-        for subpage in path[1:]:
-            subpage = subpage.encode('utf-8')
-            if redirect_page is self.vhost_root:
-                redirect_page = redirect_page.children[subpage]
-            else:
-                redirect_page = redirect_page.original.children[subpage]
-
-        if path_args is not None:
-            args = [quote(a).encode() for a in path_args]
-            request.postpath = args + request.postpath
-
-        if self._do_cache:
-            # if cache is needed, it will be handled by final page
-            redirect_page._do_cache = self._do_cache
-            self._do_cache = None
-
-        defer.ensureDeferred(
-            redirect_page.render_page(request, skip_parse_url=skip_parse_url)
-        )
-        raise failure.Failure(exceptions.CancelError("page redirection is used"))
-
-    def page_error(self, request, code=C.HTTP_NOT_FOUND, no_body=False):
-        """generate an error page and terminate the request
-
-        @param request(server.Request): HTTP request
-        @param core(int): error code to use
-        @param no_body: don't write body if True
-        """
-        if self._do_cache is not None:
-            # we don't want to cache error pages
-            self._do_cache = None
-        request.setResponseCode(code)
-        if no_body:
-            request.finish()
-        else:
-            template = "error/" + str(code) + ".html"
-            template_data = request.template_data
-            session_data = self.host.get_session_data(request, session_iface.IWebSession)
-            if session_data.locale is not None:
-                template_data['locale'] = session_data.locale
-            if self.vhost_root.site_name:
-                template_data['site'] = self.vhost_root.site_name
-
-            rendered = self.host.renderer.render(
-                template,
-                theme=session_data.theme or self.default_theme,
-                media_path=f"/{C.MEDIA_DIR}",
-                build_path=f"/{C.BUILD_DIR}/",
-                site_themes=self.site_themes,
-                error_code=code,
-                **template_data
-            )
-
-            self.write_data(rendered, request)
-        raise failure.Failure(exceptions.CancelError("error page is used"))
-
-    def write_data(self, data, request):
-        """write data to transport and finish the request"""
-        if data is None:
-            self.page_error(request)
-        data_encoded = data.encode("utf-8")
-
-        if self._do_cache is not None:
-            redirected_page = self._do_cache.pop(0)
-            cache = reduce(lambda d, k: d.setdefault(k, {}), self._do_cache, self.cache)
-            page_cache = cache[redirected_page] = CachePage(data_encoded)
-            self._set_cache_headers(request, page_cache)
-            log.debug(_("{page} put in cache for [{profile}]")
-                .format( page=self, profile=self._do_cache[0]))
-            self._do_cache = None
-            self._check_cache_headers(request, page_cache)
-
-        try:
-            request.write(data_encoded)
-        except AttributeError:
-            log.warning(_("Can't write page, the request has probably been cancelled "
-                          "(browser tab closed or reloaded)"))
-            return
-        request.finish()
-
-    def _subpages_handler(self, request):
-        """render subpage if suitable
-
-        this method checks if there is still an unmanaged part of the path
-        and check if it corresponds to a subpage. If so, it render the subpage
-        else it render a NoResource.
-        If there is no unmanaged part of the segment, current page workflow is pursued
-        """
-        if request.postpath:
-            subpage = self.next_path(request).encode('utf-8')
-            try:
-                child = self.children[subpage]
-            except KeyError:
-                self.page_error(request)
-            else:
-                child.render(request)
-                raise failure.Failure(exceptions.CancelError("subpage page is used"))
-
-    def _prepare_dynamic(self, request):
-        session_data = self.host.get_session_data(request, session_iface.IWebSession)
-        # we need to activate dynamic page
-        # we set data for template, and create/register token
-        # socket_token = str(uuid.uuid4())
-        socket_url = self.host.get_websocket_url(request)
-        # as for CSRF, it is important to not let the socket token if we use the service
-        # profile, as those pages can be cached, and then the token leaked.
-        socket_token = '' if session_data.profile is None else session_data.ws_token
-        socket_debug = C.bool_const(self.host.debug)
-        request.template_data["websocket"] = WebsocketMeta(
-            socket_url, socket_token, socket_debug
-        )
-        # we will keep track of handlers to remove
-        request._signals_registered = []
-        # we will cache registered signals until socket is opened
-        request._signals_cache = []
-
-    def _render_template(self, request):
-        template_data = request.template_data
-
-        # if confirm variable is set in case of successfuly data post
-        session_data = self.host.get_session_data(request, session_iface.IWebSession)
-        template_data['identities'] = session_data.identities
-        if session_data.pop_page_flag(self, C.FLAG_CONFIRM):
-            template_data["confirm"] = True
-        notifs = session_data.pop_page_notifications(self)
-        if notifs:
-            template_data["notifications"] = notifs
-        if session_data.jid is not None:
-            template_data["own_jid"] = session_data.jid
-        if session_data.locale is not None:
-            template_data['locale'] = session_data.locale
-        if session_data.guest:
-            template_data['guest_session'] = True
-        if self.vhost_root.site_name:
-            template_data['site'] = self.vhost_root.site_name
-        if self.dyn_data:
-            for data in self.dyn_data.values():
-                try:
-                    scripts = data['scripts']
-                except KeyError:
-                    pass
-                else:
-                    template_data.setdefault('scripts', utils.OrderedSet()).update(scripts)
-                template_data.update(data.get('template', {}))
-        data_common = self.vhost_root.dyn_data_common
-        common_scripts = data_common['scripts']
-        if common_scripts:
-            template_data.setdefault('scripts', utils.OrderedSet()).update(common_scripts)
-            if "template" in data_common:
-                for key, value in data_common["template"].items():
-                    if key not in template_data:
-                        template_data[key] = value
-
-        theme = session_data.theme or self.default_theme
-        self.expose_to_scripts(
-            request,
-            cache_path=session_data.cache_dir,
-            templates_root_url=str(self.vhost_root.get_front_url(theme)),
-            profile=session_data.profile)
-
-        uri = request.uri.decode()
-        try:
-            template_data["current_page"] = next(
-                m[0] for m in self.main_menu if uri.startswith(m[1])
-            )
-        except StopIteration:
-            pass
-
-        return self.host.renderer.render(
-            self.template,
-            theme=theme,
-            site_themes=self.site_themes,
-            page_url=self.get_url(),
-            media_path=f"/{C.MEDIA_DIR}",
-            build_path=f"/{C.BUILD_DIR}/",
-            cache_path=session_data.cache_dir,
-            main_menu=self.main_menu,
-            **template_data)
-
-    def _on_data_post_redirect(self, ret, request):
-        """called when page's on_data_post has been done successfuly
-
-        This will do a Post/Redirect/Get pattern.
-        this method redirect to the same page or to request.data['post_redirect_page']
-        post_redirect_page can be either a page or a tuple with page as first item, then
-        a list of unicode arguments to append to the url.
-        if post_redirect_page is not used, initial request.uri (i.e. the same page as
-        where the data have been posted) will be used for redirection.
-        HTTP status code "See Other" (303) is used as it is the recommanded code in
-        this case.
-        @param ret(None, unicode, iterable): on_data_post return value
-            see LiberviaPage.__init__ on_data_post docstring
-        """
-        if ret is None:
-            ret = ()
-        elif isinstance(ret, str):
-            ret = (ret,)
-        else:
-            ret = tuple(ret)
-            raise NotImplementedError(
-                _("iterable in on_data_post return value is not used yet")
-            )
-        session_data = self.host.get_session_data(request, session_iface.IWebSession)
-        request_data = self.get_r_data(request)
-        if "post_redirect_page" in request_data:
-            redirect_page_data = request_data["post_redirect_page"]
-            if isinstance(redirect_page_data, tuple):
-                redirect_page = redirect_page_data[0]
-                redirect_page_args = redirect_page_data[1:]
-                redirect_uri = redirect_page.get_url(*redirect_page_args)
-            else:
-                redirect_page = redirect_page_data
-                redirect_uri = redirect_page.url
-        else:
-            redirect_page = self
-            redirect_uri = request.uri
-
-        if not C.POST_NO_CONFIRM in ret:
-            session_data.set_page_flag(redirect_page, C.FLAG_CONFIRM)
-        request.setResponseCode(C.HTTP_SEE_OTHER)
-        request.setHeader(b"location", redirect_uri)
-        request.finish()
-        raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used"))
-
-    async def _on_data_post(self, request):
-        self.check_csrf(request)
-        try:
-            ret = await as_deferred(self.on_data_post, self, request)
-        except exceptions.DataError as e:
-            # something is wrong with the posted data, we re-display the page with a
-            # warning notification
-            session_data = self.host.get_session_data(request, session_iface.IWebSession)
-            session_data.set_page_notification(self, str(e), C.LVL_WARNING)
-            request.setResponseCode(C.HTTP_SEE_OTHER)
-            request.setHeader("location", request.uri)
-            request.finish()
-            raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used"))
-        else:
-            if ret != "continue":
-                self._on_data_post_redirect(ret, request)
-
-    def get_posted_data(
-            self,
-            request: server.Request,
-            keys,
-            multiple: bool = False,
-            raise_on_missing: bool = True,
-            strip: bool = True
-    ):
-        """Get data from a POST request or from URL's query part and decode it
-
-        @param request: request linked to the session
-        @param keys(unicode, iterable[unicode]): name of the value(s) to get
-            unicode to get one value
-            iterable to get more than one
-        @param multiple: True if multiple values are possible/expected
-            if False, the first value is returned
-        @param raise_on_missing: raise KeyError on missing key if True
-            else use None for missing values
-        @param strip: if True, apply "strip()" on values
-        @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]):
-            values received for this(these) key(s)
-        @raise KeyError: one specific key has been requested, and it is missing
-        """
-        #  FIXME: request.args is already unquoting the value, it seems we are doing
-        #         double unquote
-        if isinstance(keys, str):
-            keys = [keys]
-
-        keys = [k.encode('utf-8') for k in keys]
-
-        ret = []
-        for key in keys:
-            gen = (urllib.parse.unquote(v.decode("utf-8"))
-                                        for v in request.args.get(key, []))
-            if multiple:
-                ret.append(gen.strip() if strip else gen)
-            else:
-                try:
-                    v = next(gen)
-                except StopIteration:
-                    if raise_on_missing:
-                        raise KeyError(key)
-                    else:
-                        ret.append(None)
-                else:
-                    ret.append(v.strip() if strip else v)
-
-        if len(keys) == 1:
-            return ret[0]
-        else:
-            return ret
-
-    def get_all_posted_data(self, request, except_=(), multiple=True):
-        """get all posted data
-
-        @param request(server.Request): request linked to the session
-        @param except_(iterable[unicode]): key of values to ignore
-            csrf_token will always be ignored
-        @param multiple(bool): if False, only the first values are returned
-        @return (dict[unicode, list[unicode]]): post values
-        """
-        except_ = tuple(except_) + ("csrf_token",)
-        ret = {}
-        for key, values in request.args.items():
-            key = key.decode('utf-8')
-            key = urllib.parse.unquote(key)
-            if key in except_:
-                continue
-            values = [v.decode('utf-8') for v in values]
-            if not multiple:
-                ret[key] = urllib.parse.unquote(values[0])
-            else:
-                ret[key] = [urllib.parse.unquote(v) for v in values]
-        return ret
-
-    def get_profile(self, request):
-        """Helper method to easily get current profile
-
-        @return (unicode, None): current profile
-            None if no profile session is started
-        """
-        web_session = self.host.get_session_data(request, session_iface.IWebSession)
-        return web_session.profile
-
-    def get_jid(self, request):
-        """Helper method to easily get current jid
-
-        @return: current jid
-        """
-        web_session = self.host.get_session_data(request, session_iface.IWebSession)
-        return web_session.jid
-
-
-    def get_r_data(self, request):
-        """Helper method to get request data dict
-
-        this dictionnary if for the request only, it is not saved in session
-        It is mainly used to pass data between pages/methods called during request
-        workflow
-        @return (dict): request data
-        """
-        try:
-            return request.data
-        except AttributeError:
-            request.data = {}
-            return request.data
-
-    def get_page_data(self, request, key):
-        """Helper method to retrieve reload resistant data"""
-        web_session = self.host.get_session_data(request, session_iface.IWebSession)
-        return web_session.get_page_data(self, key)
-
-    def set_page_data(self, request, key, value):
-        """Helper method to set reload resistant data"""
-        web_session = self.host.get_session_data(request, session_iface.IWebSession)
-        return web_session.set_page_data(self, key, value)
-
-    def handle_search(self, request, extra):
-        """Manage Full-Text Search
-
-        Check if "search" query argument is present, and add MAM filter for it if
-        necessary.
-        If used, the "search" variable will also be available in template data, thus
-        frontend can display some information about it.
-        """
-        search = self.get_posted_data(request, 'search', raise_on_missing=False)
-        if search is not None:
-            search = search.strip()
-            if search:
-                try:
-                    extra[f'mam_filter_{self.host.ns_map["fulltextmam"]}'] = search
-                except KeyError:
-                    log.warning(_("Full-text search is not available"))
-                else:
-                    request.template_data['search'] = search
-
-    def _check_access(self, request):
-        """Check access according to self.access
-
-        if access is not granted, show a HTTP_FORBIDDEN page_error and stop request,
-        else return data (so it can be inserted in deferred chain
-        """
-        if self.access == C.PAGES_ACCESS_PUBLIC:
-            pass
-        elif self.access == C.PAGES_ACCESS_PROFILE:
-            profile = self.get_profile(request)
-            if not profile:
-                # registration allowed, we redirect to login page
-                login_url = self.get_page_redirect_url(request)
-                self.http_redirect(request, login_url)
-
-    def set_best_locale(self, request):
-        """Guess the best locale when it is not specified explicitly by user
-
-        This method will check "accept-language" header, and set locale to first
-        matching value with available translations.
-        """
-        accept_language = request.getHeader("accept-language")
-        if not accept_language:
-            return
-        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()
-            if not lang:
-                continue
-            for a in available:
-                if a.lower().startswith(lang):
-                    session_data = self.host.get_session_data(request,
-                                                            session_iface.IWebSession)
-                    session_data.locale = a
-                    return
-
-    async def render_page(self, request, skip_parse_url=False):
-        """Main method to handle the workflow of a LiberviaPage"""
-        # template_data are the variables passed to template
-        if not hasattr(request, "template_data"):
-            # if template_data doesn't exist, it's the beginning of the request workflow
-            # so we fill essential data
-            session_data = self.host.get_session_data(request, session_iface.IWebSession)
-            profile = session_data.profile
-            request.template_data = {
-                "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": []
-            }
-
-            # XXX: here is the code which need to be executed once
-            #      at the beginning of the request hanling
-            if request.postpath and not request.postpath[-1]:
-                # we don't differenciate URLs finishing with '/' or not
-                del request.postpath[-1]
-
-            # i18n
-            key_lang = C.KEY_LANG.encode()
-            if key_lang in request.args:
-                try:
-                    locale = request.args.pop(key_lang)[0].decode()
-                except IndexError:
-                    log.warning("empty lang received")
-                else:
-                    if "/" in locale:
-                        # "/" is refused because locale may sometime be used to access
-                        # path, if localised documents are available for instance
-                        log.warning(_('illegal char found in locale ("/"), hack '
-                                      'attempt? locale={locale}').format(locale=locale))
-                        locale = None
-                    session_data.locale = locale
-
-            # if locale is not specified, we try to find one requested by browser
-            if session_data.locale is None:
-                self.set_best_locale(request)
-
-            # theme
-            key_theme = C.KEY_THEME.encode()
-            if key_theme in request.args:
-                theme = request.args.pop(key_theme)[0].decode()
-                if key_theme != session_data.theme:
-                    if theme not in self.site_themes:
-                        log.warning(_(
-                            "Theme {theme!r} doesn't exist for {vhost}"
-                            .format(theme=theme, vhost=self.vhost_root)))
-                    else:
-                        session_data.theme = theme
-        try:
-
-            try:
-                self._check_access(request)
-
-                if self.redirect is not None:
-                    self.page_redirect(self.redirect, request, skip_parse_url=False)
-
-                if self.parse_url is not None and not skip_parse_url:
-                    if self.url_cache:
-                        profile = self.get_profile(request)
-                        try:
-                            cache_url = self.cached_urls[profile][request.uri]
-                        except KeyError:
-                            # no cache for this URI yet
-                            #  we do normal URL parsing, and then the cache
-                            await as_deferred(self.parse_url, self, request)
-                            self._cache_url(request, profile)
-                        else:
-                            log.debug(f"using URI cache for {self}")
-                            cache_url.use(request)
-                    else:
-                        await as_deferred(self.parse_url, self, request)
-
-                if self.add_breadcrumb is None:
-                    label = (
-                        self.label
-                        or self.name
-                        or self.url[self.url.rfind('/')+1:]
-                    )
-                    breadcrumb = {
-                        "url": self.url,
-                        "label": label.title(),
-                    }
-                    request.template_data["breadcrumbs"].append(breadcrumb)
-                else:
-                    await as_deferred(
-                        self.add_breadcrumb,
-                        self,
-                        request,
-                        request.template_data["breadcrumbs"]
-                    )
-
-                self._subpages_handler(request)
-
-                if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST):
-                    # only HTTP GET and POST are handled so far
-                    self.page_error(request, C.HTTP_BAD_REQUEST)
-
-                if request.method == C.HTTP_METHOD_POST:
-                    if self.on_data_post == 'continue':
-                        pass
-                    elif self.on_data_post is None:
-                        # if we don't have on_data_post, the page was not expecting POST
-                        # so we return an error
-                        self.page_error(request, C.HTTP_BAD_REQUEST)
-                    else:
-                        await self._on_data_post(request)
-                    # by default, POST follow normal behaviour after on_data_post is called
-                    # this can be changed by a redirection or other method call in on_data_post
-
-                if self.dynamic:
-                    self._prepare_dynamic(request)
-
-                if self.prepare_render:
-                    await as_deferred(self.prepare_render, self, request)
-
-                if self.template:
-                    rendered = self._render_template(request)
-                elif self.render_method:
-                    rendered = await as_deferred(self.render_method, self, request)
-                else:
-                    raise exceptions.InternalError(
-                        "No method set to render page, please set a template or use a "
-                        "render method"
-                    )
-
-                self.write_data(rendered, request)
-
-            except failure.Failure as f:
-                # we have to unpack the Failure to catch the right Exception
-                raise f.value
-
-        except exceptions.CancelError:
-            pass
-        except BridgeException as e:
-            if e.condition == 'not-allowed':
-                log.warning("not allowed exception catched")
-                self.page_error(request, C.HTTP_FORBIDDEN)
-            elif e.condition == 'item-not-found' or e.classname == 'NotFound':
-                self.page_error(request, C.HTTP_NOT_FOUND)
-            elif e.condition == 'remote-server-not-found':
-                self.page_error(request, C.HTTP_NOT_FOUND)
-            elif e.condition == 'forbidden':
-                if self.get_profile(request) is None:
-                    log.debug("access forbidden, we're redirecting to log-in page")
-                    self.http_redirect(request, self.get_page_redirect_url(request))
-                else:
-                    self.page_error(request, C.HTTP_FORBIDDEN)
-            else:
-                log.error(
-                    _("Uncatched bridge exception for HTTP request on {url}: {e}\n"
-                      "page name: {name}\npath: {path}\nURL: {full_url}\n{tb}")
-                    .format(
-                        url=self.url,
-                        e=e,
-                        name=self.name or "",
-                        path=self.root_dir,
-                        full_url=request.URLPath(),
-                        tb=traceback.format_exc(),
-                    )
-                )
-                try:
-                    self.page_error(request, C.HTTP_INTERNAL_ERROR)
-                except exceptions.CancelError:
-                    pass
-        except Exception as e:
-            log.error(
-                _("Uncatched error for HTTP request on {url}: {e}\npage name: "
-                  "{name}\npath: {path}\nURL: {full_url}\n{tb}")
-                .format(
-                    url=self.url,
-                    e=e,
-                    name=self.name or "",
-                    path=self.root_dir,
-                    full_url=request.URLPath(),
-                    tb=traceback.format_exc(),
-                )
-            )
-            try:
-                self.page_error(request, C.HTTP_INTERNAL_ERROR)
-            except exceptions.CancelError:
-                pass
-
-    def render_GET(self, request):
-        defer.ensureDeferred(self.render_page(request))
-        return server.NOT_DONE_YET
-
-    def render_POST(self, request):
-        defer.ensureDeferred(self.render_page(request))
-        return server.NOT_DONE_YET
--- a/libervia/server/pages_tools.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,60 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia Web frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-"""Helper methods for common operations on pages"""
-
-from twisted.internet import defer
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import data_format
-from libervia.server.constants import Const as C
-
-
-log = getLogger(__name__)
-
-
-def deserialise(comments_data_s):
-    return data_format.deserialise(comments_data_s)
-
-
-def retrieve_comments(self, service, node, profile, pass_exceptions=True):
-    """Retrieve comments from server and convert them to data objects
-
-    @param service(unicode): service holding the comments
-    @param node(unicode): node to retrieve
-    @param profile(unicode): profile of the user willing to find comments
-    @param pass_exceptions(bool): if True bridge exceptions will be ignored but logged
-        else exception will be raised
-    """
-    try:
-        d = self.host.bridge_call(
-            "mb_get", service, node, C.NO_LIMIT, [], data_format.serialise({}), profile
-        )
-    except Exception as e:
-        if not pass_exceptions:
-            raise e
-        else:
-            log.warning(
-                _("Can't get comments at {node} (service: {service}): {msg}").format(
-                    service=service, node=node, msg=e
-                )
-            )
-            return defer.succeed([])
-
-    d.addCallback(deserialise)
-    return d
--- a/libervia/server/proxy.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,79 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-from twisted.web import proxy
-from twisted.python.compat import urlquote
-from twisted.internet import address
-from sat.core.log import getLogger
-from libervia.server.constants import Const as C
-
-log = getLogger(__name__)
-
-
-
-class SatProxyClient(proxy.ProxyClient):
-
-    def handleHeader(self, key, value):
-        if key.lower() == b"x-frame-options":
-            value = b"sameorigin"
-        elif key.lower() == b"content-security-policy":
-            value = value.replace(b"frame-ancestors 'none'", b"frame-ancestors 'self'")
-
-        super().handleHeader(key, value)
-
-
-class SatProxyClientFactory(proxy.ProxyClientFactory):
-    protocol = SatProxyClient
-
-
-class SatReverseProxyResource(proxy.ReverseProxyResource):
-    """Resource Proxy rewritting headers to allow embedding in iframe on same domain"""
-    proxyClientFactoryClass = SatProxyClientFactory
-
-    def getChild(self, path, request):
-        return SatReverseProxyResource(
-            self.host, self.port,
-            self.path + b'/' + urlquote(path, safe=b"").encode('utf-8'),
-            self.reactor
-        )
-
-    def render(self, request):
-        # Forwarded and X-Forwarded-xxx headers can be set
-        # if we have behind an other proxy
-        if ((not request.getHeader(C.H_FORWARDED)
-             and not request.getHeader(C.H_X_FORWARDED_HOST))):
-            forwarded_data = []
-            addr = request.getClientAddress()
-            if ((isinstance(addr, address.IPv4Address)
-                 or isinstance(addr, address.IPv6Address))):
-                request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_FOR, [addr.host])
-                forwarded_data.append(f"for={addr.host}")
-            host = request.getHeader("host")
-            if host is None:
-                port = request.getHost().port
-                hostname = request.getRequestHostname()
-                host = hostname if port in (80, 443) else f"{hostname}:{port}"
-            request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_HOST, [host])
-            forwarded_data.append(f"host={host}")
-            proto = "https" if request.isSecure() else "http"
-            request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_PROTO, [proto])
-            forwarded_data.append(f"proto={proto}")
-            request.requestHeaders.setRawHeaders(
-                C.H_FORWARDED, [";".join(forwarded_data)]
-            )
-
-        return super().render(request)
--- a/libervia/server/resources.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,708 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia Web
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import os.path
-from pathlib import Path
-import urllib.error
-import urllib.parse
-import urllib.request
-
-from twisted.internet import defer
-from twisted.web import server
-from twisted.web import static
-from twisted.web import resource as web_resource
-
-from libervia.server.constants import Const as C
-from libervia.server.utils import quote
-from sat.core import exceptions
-from sat.core.i18n import D_, _
-from sat.core.log import getLogger
-from sat.tools.common import uri as common_uri
-from sat.tools.common import data_format
-from sat.tools.common.utils import OrderedSet, recursive_update
-
-from . import proxy
-
-log = getLogger(__name__)
-
-
-class ProtectedFile(static.File):
-    """A static.File class which doesn't show directory listing"""
-
-    def __init__(self, path, *args, **kwargs):
-        if "defaultType" not in kwargs and len(args) < 2:
-            # defaultType is second positional argument, and Twisted uses it
-            # in File.createSimilarFile, so we set kwargs only if it is missing
-            # in kwargs and it is not in a positional argument
-            kwargs["defaultType"] = "application/octet-stream"
-        super(ProtectedFile, self).__init__(str(path), *args, **kwargs)
-
-    def directoryListing(self):
-        return web_resource.NoResource()
-
-
-    def getChild(self, path, request):
-        return super().getChild(path, request)
-
-    def getChildWithDefault(self, path, request):
-        return super().getChildWithDefault(path, request)
-
-    def getChildForRequest(self, request):
-        return super().getChildForRequest(request)
-
-
-class LiberviaRootResource(ProtectedFile):
-    """Specialized resource for Libervia root
-
-    handle redirections declared in sat.conf
-    """
-
-    def __init__(self, host, host_name, site_name, site_path, *args, **kwargs):
-        ProtectedFile.__init__(self, *args, **kwargs)
-        self.host = host
-        self.host_name = host_name
-        self.site_name = site_name
-        self.site_path = Path(site_path)
-        self.default_theme = self.config_get('theme')
-        if self.default_theme is None:
-            if not host_name:
-                # FIXME: we use bulma theme by default for main site for now
-                #   as the development is focusing on this one, and default theme may
-                #   be broken
-                self.default_theme = 'bulma'
-            else:
-                self.default_theme =  C.TEMPLATE_THEME_DEFAULT
-        self.site_themes = set()
-        self.named_pages = {}
-        self.browser_modules = {}
-        # template dynamic data used in all pages
-        self.dyn_data_common = {"scripts": OrderedSet()}
-        for theme, data in host.renderer.get_themes_data(site_name).items():
-            # we check themes for browser metadata, and merge them here if found
-            self.site_themes.add(theme)
-            browser_meta = data.get('browser_meta')
-            if browser_meta is not None:
-                log.debug(f"merging browser metadata from theme {theme}: {browser_meta}")
-                recursive_update(self.browser_modules, browser_meta)
-            browser_path = data.get('browser_path')
-            if browser_path is not None:
-                self.browser_modules.setdefault('themes_browser_paths', set()).add(
-                    browser_path)
-                try:
-                    next(browser_path.glob("*.py"))
-                except StopIteration:
-                    pass
-                else:
-                    log.debug(f"found brython script(s) for theme {theme}")
-                    self.browser_modules.setdefault('brython', []).append(
-                        {
-                            "path": browser_path,
-                            "url_hash": None,
-                            "url_prefix": f"__t_{theme}"
-                        }
-                    )
-
-        self.uri_callbacks = {}
-        self.pages_redirects = {}
-        self.cached_urls = {}
-        self.main_menu = None
-        # map Libervia application names => data
-        self.libervia_apps = {}
-        self.build_path = host.get_build_path(site_name)
-        self.build_path.mkdir(parents=True, exist_ok=True)
-        self.dev_build_path = host.get_build_path(site_name, dev=True)
-        self.dev_build_path.mkdir(parents=True, exist_ok=True)
-        self.putChild(
-            C.BUILD_DIR.encode(),
-            ProtectedFile(
-                self.build_path,
-                defaultType="application/octet-stream"),
-        )
-
-    def __str__(self):
-        return (
-            f"Root resource for {self.host_name or 'default host'} using "
-            f"{self.site_name or 'default site'} at {self.site_path} and deserving "
-            f"files at {self.path}"
-        )
-
-    def config_get(self, key, default=None, value_type=None):
-        """Retrieve configuration for this site
-
-        params are the same as for [Libervia.config_get]
-        """
-        return self.host.config_get(self, key, default, value_type)
-
-    def get_front_url(self, theme):
-        return Path(
-            '/',
-            C.TPL_RESOURCE,
-            self.site_name or C.SITE_NAME_DEFAULT,
-            C.TEMPLATE_TPL_DIR,
-            theme)
-
-    def add_resource_to_path(self, path: str, resource: web_resource.Resource) -> None:
-        """Add a resource to the given path
-
-        A "NoResource" will be used for all intermediate segments
-        """
-        segments, __, last_segment = path.rpartition("/")
-        url_segments = segments.split("/") if segments else []
-        current = self
-        for segment in url_segments:
-            resource = web_resource.NoResource()
-            current.putChild(segment, resource)
-            current = resource
-
-        current.putChild(
-            last_segment.encode('utf-8'),
-            resource
-        )
-
-    async def _start_app(self, app_name, extra=None) -> dict:
-        """Start a Libervia App
-
-        @param app_name: canonical application name
-        @param extra: extra parameter to configure app
-        @return: app data
-            app data will not include computed exposed data, at this needs to wait for the
-            app to be started
-        """
-        if extra is None:
-            extra = {}
-        log.info(_(
-            "starting application {app_name}").format(app_name=app_name))
-        app_data = data_format.deserialise(
-            await self.host.bridge_call(
-                "application_start", app_name, data_format.serialise(extra)
-            )
-        )
-        if app_data.get("started", False):
-            log.debug(f"application {app_name!r} is already started or starting")
-            # we do not await on purpose, the workflow should not be blocking at this
-            # point
-            defer.ensureDeferred(self._on_app_started(app_name, app_data["instance"]))
-        else:
-            self.host.apps_cb[app_data["instance"]] = self._on_app_started
-        return app_data
-
-    async def _on_app_started(
-        self,
-        app_name: str,
-        instance_id: str
-    ) -> None:
-        exposed_data = self.libervia_apps[app_name] = data_format.deserialise(
-            await self.host.bridge_call("application_exposed_get", app_name, "", "")
-        )
-
-        try:
-            web_port = int(exposed_data['ports']['web'].split(':')[1])
-        except (KeyError, ValueError):
-            log.warning(_(
-                "no web port found for application {app_name!r}, can't use it "
-                ).format(app_name=app_name))
-            raise exceptions.DataError("no web port found")
-
-        try:
-            url_prefix = exposed_data['url_prefix'].strip().rstrip('/')
-        except (KeyError, AttributeError) as e:
-            log.warning(_(
-                "no URL prefix specified for this application, we can't embed it: {msg}")
-                .format(msg=e))
-            raise exceptions.DataError("no URL prefix")
-
-        if not url_prefix.startswith('/'):
-            raise exceptions.DataError(
-                f"invalid URL prefix, it must start with '/': {url_prefix!r}")
-
-        res = proxy.SatReverseProxyResource(
-            "localhost",
-            web_port,
-            url_prefix.encode()
-        )
-        self.add_resource_to_path(url_prefix, res)
-        log.info(
-            f"Resource for app {app_name!r} (instance {instance_id!r}) has been added"
-        )
-
-    async def _init_redirections(self, options):
-        url_redirections = options["url_redirections_dict"]
-
-        url_redirections = url_redirections.get(self.site_name, {})
-
-        ## redirections
-        self.redirections = {}
-        self.inv_redirections = {}  # new URL to old URL map
-
-        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 get_url 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
-                        )
-                    )
-                    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._normalize_url(old)
-
-                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.get_page_by_name(new["page"])
-                            url = page.get_url(*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)
-
-                # we handle the known URL schemes
-                if new_url.scheme == "xmpp":
-                    location = self.get_page_path_from_uri(new)
-                    if location is None:
-                        log.warning(
-                            _("ignoring redirection, no page found to handle this URI: "
-                              "{uri}").format(uri=new))
-                        continue
-                    request_data = self._get_request_data(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._get_request_data(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.add_resource_to_path(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 == "libervia-app":
-                    # a Libervia application
-
-                    app_name = urllib.parse.unquote(new_url.path).lower().strip()
-                    extra = {"url_prefix": f"/{old}"}
-                    try:
-                        await self._start_app(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(
-                        f"[{self.host_name}] Added redirection from /{old} to "
-                        f"application {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
-                    if host is None or port is None:
-                        raise ValueError(
-                            "invalid host or port in proxy redirection, please check your "
-                            "configuration: {new_url.geturl()}"
-                        )
-                    url_prefix = (new_url.path or old).rstrip('/')
-                    res = proxy.SatReverseProxyResource(
-                        host,
-                        port,
-                        url_prefix.encode(),
-                    )
-                    self.add_resource_to_path(old, res)
-                    log.info(
-                        f"[{self.host_name}] Added redirection from /{old} to reverse proxy "
-                        f"{new_url.netloc} with URL prefix {url_prefix}/"
-                    )
-
-                    # 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
-                        )
-                    )
-
-                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:
-            self.redirections[""] = self._get_request_data(C.LIBERVIA_PAGE_START)
-
-    async def _set_menu(self, menus):
-        menus = menus.get(self.site_name, [])
-        main_menu = []
-        for menu in menus:
-            if not menu:
-                msg = _("menu item can't be empty")
-                log.error(msg)
-                raise ValueError(msg)
-            elif isinstance(menu, list):
-                if len(menu) != 2:
-                    msg = _(
-                        "menu item as list must be in the form [page_name, absolue URL]"
-                    )
-                    log.error(msg)
-                    raise ValueError(msg)
-                page_name, url = menu
-            elif menu.startswith("libervia-app:"):
-                app_name = menu[13:].strip().lower()
-                app_data = await self._start_app(app_name)
-                exposed_data = app_data["expose"]
-                front_url = exposed_data['front_url']
-                options = self.host.options
-                url_redirections = options["url_redirections_dict"].setdefault(
-                    self.site_name, {}
-                )
-                if front_url in url_redirections:
-                    raise exceptions.ConflictError(
-                        f"There is already a redirection from {front_url!r}, can't add "
-                        f"{app_name!r}")
-
-                url_redirections[front_url] = {
-                    "page": 'embed_app',
-                    "path_args": [app_name]
-                }
-
-                page_name = exposed_data.get('web_label', app_name).title()
-                url = front_url
-
-                log.debug(
-                    f"Application {app_name} added to menu of {self.site_name}"
-                )
-            else:
-                page_name = menu
-                try:
-                    url = self.get_page_by_name(page_name).url
-                except KeyError as e:
-                    log_msg = _("Can'find a named page ({msg}), please check "
-                                "menu_json in configuration.").format(msg=e.args[0])
-                    log.error(log_msg)
-                    raise exceptions.ConfigError(log_msg)
-            main_menu.append((page_name, url))
-        self.main_menu = main_menu
-
-    def _normalize_url(self, url, lower=True):
-        """Return URL normalized for self.redirections dict
-
-        @param url(unicode): URL to normalize
-        @param lower(bool): lower case of url if True
-        @return (str): normalized URL
-        """
-        if lower:
-            url = url.lower()
-        return "/".join((p for p in url.split("/") if p))
-
-    def _get_request_data(self, uri):
-        """Return data needed to redirect request
-
-        @param url(unicode): destination url
-        @return (tuple(list[str], str, str, dict): tuple with
-            splitted path as in Request.postpath
-            uri as in Request.uri
-            path as in Request.path
-            args as in Request.args
-        """
-        uri = uri
-        # XXX: we reuse code from twisted.web.http.py here
-        #      as we need to have the same behaviour
-        x = uri.split("?", 1)
-
-        if len(x) == 1:
-            path = uri
-            args = {}
-        else:
-            path, argstring = x
-            args = urllib.parse.parse_qs(argstring, True)
-
-        # XXX: splitted path case must not be changed, as it may be significant
-        #      (e.g. for blog items)
-        return (
-            self._normalize_url(path, lower=False).split("/"),
-            uri,
-            path,
-            args,
-        )
-
-    def _redirect(self, request, request_data):
-        """Redirect an URL by rewritting request
-
-        this is *NOT* a HTTP redirection, but equivalent to URL rewritting
-        @param request(web.http.request): original request
-        @param request_data(tuple): data returned by self._get_request_data
-        @return (web_resource.Resource): resource to use
-        """
-        # recursion check
-        try:
-            request._redirected
-        except AttributeError:
-            pass
-        else:
-            try:
-                __, uri, __, __ = request_data
-            except ValueError:
-                uri = ""
-            log.error(D_( "recursive redirection, please fix this URL:\n"
-                          "{old} ==> {new}").format(
-                          old=request.uri.decode("utf-8"), new=uri))
-            return web_resource.NoResource()
-
-        request._redirected = True  # here to avoid recursive redirections
-
-        if isinstance(request_data, dict):
-            if request_data["type"] == "page":
-                try:
-                    page = self.get_page_by_name(request_data["page"])
-                except KeyError:
-                    log.error(
-                        _(
-                            'Can\'t find page named "{name}" requested in redirection'
-                        ).format(name=request_data["page"])
-                    )
-                    return web_resource.NoResource()
-                path_args = [pa.encode('utf-8') for pa in request_data["path_args"]]
-                request.postpath = path_args + request.postpath
-
-                try:
-                    request.args.update(request_data["query_args"])
-                except (TypeError, ValueError):
-                    log.error(
-                        _("Invalid args in redirection: {query_args}").format(
-                            query_args=request_data["query_args"]
-                        )
-                    )
-                    return web_resource.NoResource()
-                return page
-            else:
-                raise exceptions.InternalError("unknown request_data type")
-        else:
-            path_list, uri, path, args = request_data
-            path_list = [p.encode('utf-8') for p in path_list]
-            log.debug(
-                "Redirecting URL {old} to {new}".format(
-                    old=request.uri.decode('utf-8'), new=uri
-                )
-            )
-            # we change the request to reflect the new url
-            request.postpath = path_list[1:] + request.postpath
-            request.args.update(args)
-
-        # we start again to look for a child with the new url
-        return self.getChildWithDefault(path_list[0], request)
-
-    def get_page_by_name(self, name):
-        """Retrieve page instance from its name
-
-        @param name(unicode): name of the page
-        @return (LiberviaPage): page instance
-        @raise KeyError: the page doesn't exist
-        """
-        return self.named_pages[name]
-
-    def get_page_path_from_uri(self, uri):
-        """Retrieve page URL from xmpp: URI
-
-        @param uri(unicode): URI with a xmpp: scheme
-        @return (unicode,None): absolute path (starting from root "/") to page handling
-            the URI.
-            None is returned if no page has been registered for this URI
-        """
-        uri_data = common_uri.parse_xmpp_uri(uri)
-        try:
-            page, cb = self.uri_callbacks[uri_data["type"], uri_data["sub_type"]]
-        except KeyError:
-            url = None
-        else:
-            url = cb(page, uri_data)
-        if url is None:
-            # no handler found
-            # we try to find a more generic one
-            try:
-                page, cb = self.uri_callbacks[uri_data["type"], None]
-            except KeyError:
-                pass
-            else:
-                url = cb(page, uri_data)
-        return url
-
-    def getChildWithDefault(self, name, request):
-        # XXX: this method is overriden only for root url
-        #      which is the only ones who need to be handled before other children
-        if name == b"" and not request.postpath:
-            return self._redirect(request, self.redirections[""])
-        return super(LiberviaRootResource, self).getChildWithDefault(name, request)
-
-    def getChild(self, name, request):
-        resource = super(LiberviaRootResource, self).getChild(name, request)
-
-        if isinstance(resource, web_resource.NoResource):
-            # 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), -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]
-                    request.postpath = path_elt[idx:]
-                    return self._redirect(request, request_data)
-
-        return resource
-
-    def putChild(self, path, resource):
-        """Add a child to the root resource"""
-        if not isinstance(path, bytes):
-            raise ValueError("path must be specified in bytes")
-        if not isinstance(resource, web_resource.EncodingResourceWrapper):
-            # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders)
-            resource = web_resource.EncodingResourceWrapper(
-                resource, [server.GzipEncoderFactory()])
-
-        super(LiberviaRootResource, self).putChild(path, resource)
-
-    def createSimilarFile(self, path):
-        # XXX: this method need to be overriden to avoid recreating a LiberviaRootResource
-
-        f = LiberviaRootResource.__base__(
-            path, self.defaultType, self.ignoredExts, self.registry
-        )
-        # refactoring by steps, here - constructor should almost certainly take these
-        f.processors = self.processors
-        f.indexNames = self.indexNames[:]
-        f.childNotFound = self.childNotFound
-        return f
--- a/libervia/server/restricted_bridge.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,191 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a SàT frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from sat.tools.common import data_format
-from sat.core import exceptions
-from libervia.server.constants import Const as C
-
-
-class RestrictedBridge:
-    """bridge with limited access, which can be used in browser
-
-    Only a few method are implemented, with potentially dangerous argument controlled.
-    Security limit is used
-    """
-
-    def __init__(self, host):
-        self.host = host
-        self.security_limit = C.SECURITY_LIMIT
-
-    def no_service_profile(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 action_launch(
-        self, callback_id: str, data_s: str, profile: str
-    ) -> str:
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "action_launch", callback_id, data_s, profile
-        )
-
-    async def call_start(self, entity: str, call_data_s: str, profile: str) -> None:
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "call_start", entity, call_data_s, profile
-        )
-
-    async def call_end(self, session_id: str, call_data: str, profile: str) -> None:
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "call_end", session_id, call_data, profile
-        )
-
-    async def contacts_get(self, profile):
-        return await self.host.bridge_call("contacts_get", profile)
-
-    async def external_disco_get(self, entity, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "external_disco_get", entity, profile)
-
-    async def ice_candidates_add(self, session_id, media_ice_data_s, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "ice_candidates_add", session_id, media_ice_data_s, profile
-        )
-
-    async def identity_get(self, entity, metadata_filter, use_cache, profile):
-        return await self.host.bridge_call(
-            "identity_get", entity, metadata_filter, use_cache, profile)
-
-    async def identities_get(self, entities, metadata_filter, profile):
-        return await self.host.bridge_call(
-            "identities_get", entities, metadata_filter, profile)
-
-    async def identities_base_get(self, profile):
-        return await self.host.bridge_call(
-            "identities_base_get", profile)
-
-    async def ps_node_delete(self, service_s, node, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "ps_node_delete", service_s, node, profile)
-
-    async def ps_node_affiliations_set(self, service_s, node, affiliations, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "ps_node_affiliations_set", service_s, node, affiliations, profile)
-
-    async def ps_item_retract(self, service_s, node, item_id, notify, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "ps_item_retract", service_s, node, item_id, notify, profile)
-
-    async def mb_preview(self, service_s, node, data, profile):
-        return await self.host.bridge_call(
-            "mb_preview", service_s, node, data, profile)
-
-    async def list_set(self, service_s, node, values, schema, item_id, extra, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "list_set", service_s, node, values, "", item_id, "", profile)
-
-
-    async def file_http_upload_get_slot(
-        self, filename, size, content_type, upload_jid, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "file_http_upload_get_slot", filename, size, content_type,
-            upload_jid, profile)
-
-    async def file_sharing_delete(
-        self, service_jid, path, namespace, profile):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "file_sharing_delete", service_jid, path, namespace, profile)
-
-    async def interests_file_sharing_register(
-        self, service, repos_type, namespace, path, name, extra_s, profile
-    ):
-        self.no_service_profile(profile)
-        if extra_s:
-            # we only allow "thumb_url" here
-            extra = data_format.deserialise(extra_s)
-            if "thumb_url" in extra:
-                extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]})
-            else:
-                extra_s = ""
-
-        return await self.host.bridge_call(
-            "interests_file_sharing_register", service, repos_type, namespace, path, name,
-            extra_s, profile
-        )
-
-    async def interest_retract(
-        self, service_jid, item_id, profile
-    ):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "interest_retract", service_jid, item_id, profile)
-
-    async def ps_invite(
-        self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile
-    ):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile
-        )
-
-    async def fis_invite(
-        self, invitee_jid_s, service_s, repos_type, namespace, path, name, extra_s,
-        profile
-    ):
-        self.no_service_profile(profile)
-        if extra_s:
-            # we only allow "thumb_url" here
-            extra = data_format.deserialise(extra_s)
-            if "thumb_url" in extra:
-                extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]})
-            else:
-                extra_s = ""
-
-        return await self.host.bridge_call(
-            "fis_invite", invitee_jid_s, service_s, repos_type, namespace, path, name,
-            extra_s, profile
-        )
-
-    async def fis_affiliations_set(
-        self, service_s, namespace, path, affiliations, profile
-    ):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "fis_affiliations_set", service_s, namespace, path, affiliations, profile
-        )
-
-    async def invitation_simple_create(
-        self, invitee_email, invitee_name, url_template, extra_s, profile
-    ):
-        self.no_service_profile(profile)
-        return await self.host.bridge_call(
-            "invitation_simple_create", invitee_email, invitee_name, url_template, extra_s,
-            profile
-        )
--- a/libervia/server/server.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,1374 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia Web
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-from functools import partial
-import os.path
-from pathlib import Path
-import re
-import sys
-import time
-from typing import Callable, Dict, Optional
-import urllib.error
-import urllib.parse
-import urllib.request
-
-from twisted.application import service
-from twisted.internet import defer, inotify, reactor
-from twisted.python import failure
-from twisted.python import filepath
-from twisted.python.components import registerAdapter
-from twisted.web import server
-from twisted.web import static
-from twisted.web import resource as web_resource
-from twisted.web import util as web_util
-from twisted.web import vhost
-from twisted.words.protocols.jabber import jid
-
-import libervia
-from libervia.server import websockets
-from libervia.server import session_iface
-from libervia.server.constants import Const as C
-from libervia.server.pages import LiberviaPage
-from libervia.server.tasks.manager import TasksManager
-from libervia.server.utils import ProgressHandler
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools import utils
-from sat.tools import config
-from sat.tools.common import regex
-from sat.tools.common import template
-from sat.tools.common import data_format
-from sat.tools.common import tls
-from sat_frontends.bridge.bridge_frontend import BridgeException
-from sat_frontends.bridge.dbus_bridge import BridgeExceptionNoService, bridge
-from sat_frontends.bridge.dbus_bridge import const_TIMEOUT as BRIDGE_TIMEOUT
-
-from .resources import LiberviaRootResource, ProtectedFile
-from .restricted_bridge import RestrictedBridge
-
-log = getLogger(__name__)
-
-
-DEFAULT_MASK = (inotify.IN_CREATE | inotify.IN_MODIFY | inotify.IN_MOVE_SELF
-                | inotify.IN_MOVED_TO)
-
-
-class SysExit(Exception):
-
-    def __init__(self, exit_code, message=""):
-        self.exit_code = exit_code
-        self.message = message
-
-    def __str__(self):
-        return f"System Exit({self.exit_code}): {self.message}"
-
-
-class FilesWatcher(object):
-    """Class to check files modifications using iNotify"""
-    _notifier = None
-
-    def __init__(self, host):
-        self.host = host
-
-    @property
-    def notifier(self):
-        if self._notifier == None:
-            notifier = self.__class__._notifier = inotify.INotify()
-            notifier.startReading()
-        return self._notifier
-
-    def _check_callback(self, dir_path, callback, recursive):
-        # Twisted doesn't add callback if a watcher was already set on a path
-        # but in dev mode Libervia watches whole sites + internal path can be watched
-        # by tasks, so several callbacks must be called on some paths.
-        # This method check that the new callback is indeed present in the desired path
-        # and add it otherwise.
-        # FIXME: this should probably be fixed upstream
-        if recursive:
-            for child in dir_path.walk():
-                if child.isdir():
-                    self._check_callback(child, callback, recursive=False)
-        else:
-            watch_id = self.notifier._isWatched(dir_path)
-            if watch_id is None:
-                log.warning(
-                    f"There is no watch ID for path {dir_path}, this should not happen"
-                )
-            else:
-                watch_point = self.notifier._watchpoints[watch_id]
-                if callback not in watch_point.callbacks:
-                    watch_point.callbacks.append(callback)
-
-    def watch_dir(self, dir_path, callback, mask=DEFAULT_MASK, auto_add=False,
-                 recursive=False, **kwargs):
-        dir_path = str(dir_path)
-        log.info(_("Watching directory {dir_path}").format(dir_path=dir_path))
-        wrapped_callback = lambda __, filepath, mask: callback(
-            self.host, filepath, inotify.humanReadableMask(mask), **kwargs)
-        callbacks = [wrapped_callback]
-        dir_path = filepath.FilePath(dir_path)
-        self.notifier.watch(
-            dir_path, mask=mask, autoAdd=auto_add, recursive=recursive,
-            callbacks=callbacks)
-        self._check_callback(dir_path, wrapped_callback, recursive)
-
-
-class WebSession(server.Session):
-    sessionTimeout = C.SESSION_TIMEOUT
-
-    def __init__(self, *args, **kwargs):
-        self.__lock = False
-        server.Session.__init__(self, *args, **kwargs)
-
-    def lock(self):
-        """Prevent session from expiring"""
-        self.__lock = True
-        self._expireCall.reset(sys.maxsize)
-
-    def unlock(self):
-        """Allow session to expire again, and touch it"""
-        self.__lock = False
-        self.touch()
-
-    def touch(self):
-        if not self.__lock:
-            server.Session.touch(self)
-
-
-class WaitingRequests(dict):
-    def set_request(self, request, profile, register_with_ext_jid=False):
-        """Add the given profile to the waiting list.
-
-        @param request (server.Request): the connection request
-        @param profile (str): %(doc_profile)s
-        @param register_with_ext_jid (bool): True if we will try to register the
-            profile with an external XMPP account credentials
-        """
-        dc = reactor.callLater(BRIDGE_TIMEOUT, self.purge_request, profile)
-        self[profile] = (request, dc, register_with_ext_jid)
-
-    def purge_request(self, profile):
-        """Remove the given profile from the waiting list.
-
-        @param profile (str): %(doc_profile)s
-        """
-        try:
-            dc = self[profile][1]
-        except KeyError:
-            return
-        if dc.active():
-            dc.cancel()
-        del self[profile]
-
-    def get_request(self, profile):
-        """Get the waiting request for the given profile.
-
-        @param profile (str): %(doc_profile)s
-        @return: the waiting request or None
-        """
-        return self[profile][0] if profile in self else None
-
-    def get_register_with_ext_jid(self, profile):
-        """Get the value of the register_with_ext_jid parameter.
-
-        @param profile (str): %(doc_profile)s
-        @return: bool or None
-        """
-        return self[profile][2] if profile in self else None
-
-
-class Libervia(service.Service):
-    debug = defer.Deferred.debug  # True if twistd/Libervia is launched in debug mode
-
-    def __init__(self, options):
-        self.options = options
-        websockets.host = self
-
-    def _init(self):
-        # we do init here and not in __init__ to avoid doule initialisation with twistd
-        # this _init is called in startService
-        self.initialised = defer.Deferred()
-        self.waiting_profiles = WaitingRequests()  # FIXME: should be removed
-        self._main_conf = None
-        self.files_watcher = FilesWatcher(self)
-
-        if self.options["base_url_ext"]:
-            self.base_url_ext = self.options.pop("base_url_ext")
-            if self.base_url_ext[-1] != "/":
-                self.base_url_ext += "/"
-            self.base_url_ext_data = urllib.parse.urlsplit(self.base_url_ext)
-        else:
-            self.base_url_ext = None
-            # we split empty string anyway so we can do things like
-            # scheme = self.base_url_ext_data.scheme or 'https'
-            self.base_url_ext_data = urllib.parse.urlsplit("")
-
-        if not self.options["port_https_ext"]:
-            self.options["port_https_ext"] = self.options["port_https"]
-
-        self._cleanup = []
-
-        self.sessions = {}  # key = session value = user
-        self.prof_connected = set()  # Profiles connected
-        self.ns_map = {}  # map of short name to namespaces
-
-        ## bridge ##
-        self._bridge_retry = self.options['bridge-retries']
-        self.bridge = bridge()
-        self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
-
-        ## libervia app callbacks ##
-        # mapping instance id to the callback to call on "started" signal
-        self.apps_cb: Dict[str, Callable] = {}
-
-    @property
-    def roots(self):
-        """Return available virtual host roots
-
-        Root resources are only returned once, even if they are present for multiple
-        named vhosts. Order is not relevant, except for default vhost which is always
-        returned first.
-        @return (list[web_resource.Resource]): all vhost root resources
-        """
-        roots = list(set(self.vhost_root.hosts.values()))
-        default = self.vhost_root.default
-        if default is not None and default not in roots:
-            roots.insert(0, default)
-        return roots
-
-    @property
-    def main_conf(self):
-        """SafeConfigParser instance opened on configuration file (sat.conf)"""
-        if self._main_conf is None:
-            self._main_conf = config.parse_main_conf(log_filenames=True)
-        return self._main_conf
-
-    def config_get(self, site_root_res, key, default=None, value_type=None):
-        """Retrieve configuration associated to a site
-
-        Section is automatically set to site name
-        @param site_root_res(LiberviaRootResource): resource of the site in use
-        @param key(unicode): key to use
-        @param default: value to use if not found (see [config.config_get])
-        @param value_type(unicode, None): filter to use on value
-            Note that filters are already automatically used when the key finish
-            by a well known suffix ("_path", "_list", "_dict", or "_json")
-            None to use no filter, else can be:
-                - "path": a path is expected, will be normalized and expanded
-
-        """
-        section = site_root_res.site_name.lower().strip() or C.CONFIG_SECTION
-        value = config.config_get(self.main_conf, section, key, default=default)
-        if value_type is not None:
-            if value_type == 'path':
-                v_filter = lambda v: os.path.abspath(os.path.expanduser(v))
-            else:
-                raise ValueError("unknown value type {value_type}".format(
-                    value_type = value_type))
-            if isinstance(value, list):
-                value = [v_filter(v) for v in value]
-            elif isinstance(value, dict):
-                value = {k:v_filter(v) for k,v in list(value.items())}
-            elif value is not None:
-                value = v_filter(value)
-        return value
-
-    def _namespaces_get_cb(self, ns_map):
-        self.ns_map = {str(k): str(v) for k,v in ns_map.items()}
-
-    def _namespaces_get_eb(self, failure_):
-        log.error(_("Can't get namespaces map: {msg}").format(msg=failure_))
-
-    @template.contextfilter
-    def _front_url_filter(self, ctx, relative_url):
-        template_data = ctx['template_data']
-        return os.path.join(
-            '/', C.TPL_RESOURCE, template_data.site or C.SITE_NAME_DEFAULT,
-            C.TEMPLATE_TPL_DIR, template_data.theme, relative_url)
-
-    def _move_first_level_to_dict(self, options, key, keys_to_keep):
-        """Read a config option and put value at first level into u'' dict
-
-        This is useful to put values for Libervia official site directly in dictionary,
-        and to use site_name as keys when external sites are used.
-        options will be modified in place
-        @param options(dict): options to modify
-        @param key(unicode): setting key to modify
-        @param keys_to_keep(list(unicode)): keys allowed in first level
-        """
-        try:
-            conf = options[key]
-        except KeyError:
-            return
-        if not isinstance(conf, dict):
-            options[key] = {'': conf}
-            return
-        default_dict = conf.get('', {})
-        to_delete = []
-        for key, value in conf.items():
-            if key not in keys_to_keep:
-                default_dict[key] = value
-                to_delete.append(key)
-        for key in to_delete:
-            del conf[key]
-        if default_dict:
-            conf[''] = default_dict
-
-    async def check_and_connect_service_profile(self):
-        passphrase = self.options["passphrase"]
-        if not passphrase:
-            raise SysExit(
-                C.EXIT_BAD_ARG,
-                _("No passphrase set for service profile, please check installation "
-                  "documentation.")
-            )
-        try:
-            s_prof_connected = await self.bridge_call("is_connected", C.SERVICE_PROFILE)
-        except BridgeException as e:
-            if e.classname == "ProfileUnknownError":
-                log.info("Service profile doesn't exist, creating it.")
-                try:
-                    xmpp_domain = await self.bridge_call("config_get", "", "xmpp_domain")
-                    xmpp_domain = xmpp_domain.strip()
-                    if not xmpp_domain:
-                        raise SysExit(
-                            C.EXIT_BAD_ARG,
-                            _('"xmpp_domain" must be set to create new accounts, please '
-                              'check documentation')
-                        )
-                    service_profile_jid_s = f"{C.SERVICE_PROFILE}@{xmpp_domain}"
-                    await self.bridge_call(
-                        "in_band_account_new",
-                        service_profile_jid_s,
-                        passphrase,
-                        "",
-                        xmpp_domain,
-                        0,
-                    )
-                except BridgeException as e:
-                    if e.condition == "conflict":
-                        log.info(
-                            _("Service's profile JID {profile_jid} already exists")
-                            .format(profile_jid=service_profile_jid_s)
-                        )
-                    elif e.classname == "UnknownMethod":
-                        raise SysExit(
-                            C.EXIT_BRIDGE_ERROR,
-                            _("Can't create service profile XMPP account, In-Band "
-                              "Registration plugin is not activated, you'll have to "
-                              "create the {profile!r} profile with {profile_jid!r} JID "
-                              "manually.").format(
-                                  profile=C.SERVICE_PROFILE,
-                                  profile_jid=service_profile_jid_s)
-                        )
-                    elif e.condition == "service-unavailable":
-                        raise SysExit(
-                            C.EXIT_BRIDGE_ERROR,
-                            _("Can't create service profile XMPP account, In-Band "
-                              "Registration is not activated on your server, you'll have "
-                              "to create the {profile!r} profile with {profile_jid!r} JID "
-                              "manually.\nNote that you'll need to activate In-Band "
-                              "Registation on your server if you want users to be able "
-                              "to create new account from {app_name}, please check "
-                              "documentation.").format(
-                                  profile=C.SERVICE_PROFILE,
-                                  profile_jid=service_profile_jid_s,
-                                  app_name=C.APP_NAME)
-                        )
-                    elif e.condition == "not-acceptable":
-                        raise SysExit(
-                            C.EXIT_BRIDGE_ERROR,
-                            _("Can't create service profile XMPP account, your XMPP "
-                              "server doesn't allow us to create new accounts with "
-                              "In-Band Registration please check XMPP server "
-                              "configuration: {reason}"
-                              ).format(
-                                  profile=C.SERVICE_PROFILE,
-                                  profile_jid=service_profile_jid_s,
-                                  reason=e.message)
-                        )
-
-                    else:
-                        raise SysExit(
-                            C.EXIT_BRIDGE_ERROR,
-                            _("Can't create service profile XMPP account, you'll have "
-                              "do to it manually: {reason}").format(reason=e.message)
-                        )
-                try:
-                    await self.bridge_call("profile_create", C.SERVICE_PROFILE, passphrase)
-                    await self.bridge_call(
-                        "profile_start_session", passphrase, C.SERVICE_PROFILE)
-                    await self.bridge_call(
-                        "param_set", "JabberID", service_profile_jid_s, "Connection", -1,
-                        C.SERVICE_PROFILE)
-                    await self.bridge_call(
-                        "param_set", "Password", passphrase, "Connection", -1,
-                        C.SERVICE_PROFILE)
-                except BridgeException as e:
-                    raise SysExit(
-                        C.EXIT_BRIDGE_ERROR,
-                        _("Can't create service profile XMPP account, you'll have "
-                          "do to it manually: {reason}").format(reason=e.message)
-                    )
-                log.info(_("Service profile has been successfully created"))
-                s_prof_connected = False
-            else:
-                raise SysExit(C.EXIT_BRIDGE_ERROR, e.message)
-
-        if not s_prof_connected:
-            try:
-                await self.bridge_call(
-                    "connect",
-                    C.SERVICE_PROFILE,
-                    passphrase,
-                    {},
-                )
-            except BridgeException as e:
-                raise SysExit(
-                    C.EXIT_BRIDGE_ERROR,
-                    _("Connection of service profile failed: {reason}").format(reason=e)
-                )
-
-    async def backend_ready(self):
-        log.info(f"Libervia Web v{self.full_version}")
-
-        # settings
-        if self.options['dev-mode']:
-            log.info(_("Developer mode activated"))
-        self.media_dir = await self.bridge_call("config_get", "", "media_dir")
-        self.local_dir = await self.bridge_call("config_get", "", "local_dir")
-        self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR)
-        self.renderer = template.Renderer(self, self._front_url_filter)
-        sites_names = list(self.renderer.sites_paths.keys())
-
-        self._move_first_level_to_dict(self.options, "url_redirections_dict", sites_names)
-        self._move_first_level_to_dict(self.options, "menu_json", sites_names)
-        self._move_first_level_to_dict(self.options, "menu_extra_json", sites_names)
-        menu = self.options["menu_json"]
-        if not '' in menu:
-            menu[''] = C.DEFAULT_MENU
-        for site, value in self.options["menu_extra_json"].items():
-            menu[site].extend(value)
-
-        # service profile
-        if not self.options['build-only']:
-            await self.check_and_connect_service_profile()
-
-        # restricted bridge, the one used by browser code
-        self.restricted_bridge = RestrictedBridge(self)
-
-        # we create virtual hosts and import Libervia pages into them
-        self.vhost_root = vhost.NameVirtualHost()
-        default_site_path = Path(libervia.__file__).parent.resolve()
-        # self.sat_root is official Libervia site
-        root_path = default_site_path / C.TEMPLATE_STATIC_DIR
-        self.sat_root = default_root = LiberviaRootResource(
-            host=self, host_name='', site_name='',
-            site_path=default_site_path, path=root_path)
-        if self.options['dev-mode']:
-            self.files_watcher.watch_dir(
-                default_site_path, auto_add=True, recursive=True,
-                callback=LiberviaPage.on_file_change, site_root=self.sat_root,
-                site_path=default_site_path)
-        LiberviaPage.import_pages(self, self.sat_root)
-        tasks_manager = TasksManager(self, self.sat_root)
-        await tasks_manager.parse_tasks()
-        await tasks_manager.run_tasks()
-        # FIXME: handle _set_menu in a more generic way, taking care of external sites
-        await self.sat_root._set_menu(self.options["menu_json"])
-        self.vhost_root.default = default_root
-        existing_vhosts = {b'': default_root}
-
-        for host_name, site_name in self.options["vhosts_dict"].items():
-            if site_name == C.SITE_NAME_DEFAULT:
-                raise ValueError(
-                    f"{C.DEFAULT_SITE_NAME} is reserved and can't be used in vhosts_dict")
-            encoded_site_name = site_name.encode('utf-8')
-            try:
-                site_path = self.renderer.sites_paths[site_name]
-            except KeyError:
-                log.warning(_(
-                    "host {host_name} link to non existing site {site_name}, ignoring "
-                    "it").format(host_name=host_name, site_name=site_name))
-                continue
-            if encoded_site_name in existing_vhosts:
-                # we have an alias host, we re-use existing resource
-                res = existing_vhosts[encoded_site_name]
-            else:
-                # for root path we first check if there is a global static dir
-                # if not, we use default template's static dir
-                root_path = os.path.join(site_path, C.TEMPLATE_STATIC_DIR)
-                if not os.path.isdir(root_path):
-                    root_path = os.path.join(
-                        site_path, C.TEMPLATE_TPL_DIR, C.TEMPLATE_THEME_DEFAULT,
-                        C.TEMPLATE_STATIC_DIR)
-                res = LiberviaRootResource(
-                    host=self,
-                    host_name=host_name,
-                    site_name=site_name,
-                    site_path=site_path,
-                    path=root_path)
-
-                existing_vhosts[encoded_site_name] = res
-
-                if self.options['dev-mode']:
-                    self.files_watcher.watch_dir(
-                        site_path, auto_add=True, recursive=True,
-                        callback=LiberviaPage.on_file_change, site_root=res,
-                        # FIXME: site_path should always be a Path, check code above and
-                        # in template module
-                        site_path=Path(site_path))
-
-                LiberviaPage.import_pages(self, res)
-                # FIXME: default pages are accessible if not overriden by external website
-                #        while necessary for login or re-using existing pages
-                #        we may want to disable access to the page by direct URL
-                #        (e.g. /blog disabled except if called by external site)
-                LiberviaPage.import_pages(self, res, root_path=default_site_path)
-                tasks_manager = TasksManager(self, res)
-                await tasks_manager.parse_tasks()
-                await tasks_manager.run_tasks()
-                await res._set_menu(self.options["menu_json"])
-
-            self.vhost_root.addHost(host_name.encode('utf-8'), res)
-
-        templates_res = web_resource.Resource()
-        self.put_child_all(C.TPL_RESOURCE.encode('utf-8'), templates_res)
-        for site_name, site_path in self.renderer.sites_paths.items():
-            templates_res.putChild(site_name.encode() or C.SITE_NAME_DEFAULT.encode(),
-                                   static.File(site_path))
-
-        d = self.bridge_call("namespaces_get")
-        d.addCallback(self._namespaces_get_cb)
-        d.addErrback(self._namespaces_get_eb)
-
-        # websocket
-        if self.options["connection_type"] in ("https", "both"):
-            wss = websockets.LiberviaPageWSProtocol.get_resource(secure=True)
-            self.put_child_all(b'wss', wss)
-        if self.options["connection_type"] in ("http", "both"):
-            ws = websockets.LiberviaPageWSProtocol.get_resource(secure=False)
-            self.put_child_all(b'ws', ws)
-
-        # following signal is needed for cache handling in Libervia pages
-        self.bridge.register_signal(
-            "ps_event_raw", partial(LiberviaPage.on_node_event, self), "plugin"
-        )
-        self.bridge.register_signal(
-            "message_new", partial(self.on_signal, "message_new")
-        )
-        self.bridge.register_signal(
-            "call_accepted", partial(self.on_signal, "call_accepted"), "plugin"
-        )
-        self.bridge.register_signal(
-            "call_ended", partial(self.on_signal, "call_ended"), "plugin"
-        )
-        self.bridge.register_signal(
-            "ice_candidates_new", partial(self.on_signal, "ice_candidates_new"), "plugin"
-        )
-        self.bridge.register_signal(
-            "action_new", self.action_new_handler,
-        )
-
-        # libervia applications handling
-        self.bridge.register_signal(
-            "application_started", self.application_started_handler, "plugin"
-        )
-        self.bridge.register_signal(
-            "application_error", self.application_error_handler, "plugin"
-        )
-
-        #  Progress handling
-        self.bridge.register_signal(
-            "progress_started", partial(ProgressHandler._signal, "started")
-        )
-        self.bridge.register_signal(
-            "progress_finished", partial(ProgressHandler._signal, "finished")
-        )
-        self.bridge.register_signal(
-            "progress_error", partial(ProgressHandler._signal, "error")
-        )
-
-        # media dirs
-        # FIXME: get rid of dirname and "/" in C.XXX_DIR
-        self.put_child_all(os.path.dirname(C.MEDIA_DIR).encode('utf-8'),
-                         ProtectedFile(self.media_dir))
-
-        self.cache_resource = web_resource.NoResource()
-        self.put_child_all(C.CACHE_DIR.encode('utf-8'), self.cache_resource)
-        self.cache_resource.putChild(
-            b"common", ProtectedFile(str(self.cache_root_dir / Path("common"))))
-
-        # redirections
-        for root in self.roots:
-            await root._init_redirections(self.options)
-
-        # no need to keep url_redirections_dict, it will not be used anymore
-        del self.options["url_redirections_dict"]
-
-        server.Request.defaultContentType = "text/html; charset=utf-8"
-        wrapped = web_resource.EncodingResourceWrapper(
-            self.vhost_root, [server.GzipEncoderFactory()]
-        )
-        self.site = server.Site(wrapped)
-        self.site.sessionFactory = WebSession
-
-    def _bridge_cb(self):
-        del self._bridge_retry
-        self.bridge.ready_get(
-            lambda: self.initialised.callback(None),
-            lambda failure: self.initialised.errback(Exception(failure)),
-        )
-        self.initialised.addCallback(lambda __: defer.ensureDeferred(self.backend_ready()))
-
-    def _bridge_eb(self, failure_):
-        if isinstance(failure_, BridgeExceptionNoService):
-            if self._bridge_retry:
-                if self._bridge_retry < 0:
-                    print(_("Can't connect to bridge, will retry indefinitely. "
-                            "Next try in 1s."))
-                else:
-                    self._bridge_retry -= 1
-                    print(
-                        _(
-                            "Can't connect to bridge, will retry in 1 s ({retries_left} "
-                            "trie(s) left)."
-                        ).format(retries_left=self._bridge_retry)
-                    )
-                time.sleep(1)
-                self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
-                return
-
-            print("Can't connect to SàT backend, are you sure it's launched ?")
-        else:
-            log.error("Can't connect to bridge: {}".format(failure))
-        sys.exit(1)
-
-    @property
-    def version(self):
-        """Return the short version of Libervia"""
-        return C.APP_VERSION
-
-    @property
-    def full_version(self):
-        """Return the full version of Libervia (with extra data when in dev mode)"""
-        version = self.version
-        if version[-1] == "D":
-            # we are in debug version, we add extra data
-            try:
-                return self._version_cache
-            except AttributeError:
-                self._version_cache = "{} ({})".format(
-                    version, utils.get_repository_data(libervia)
-                )
-                return self._version_cache
-        else:
-            return version
-
-    def bridge_call(self, method_name, *args, **kwargs):
-        """Call an asynchronous bridge method and return a deferred
-
-        @param method_name: name of the method as a unicode
-        @return: a deferred which trigger the result
-
-        """
-        d = defer.Deferred()
-
-        def _callback(*args):
-            if not args:
-                d.callback(None)
-            else:
-                if len(args) != 1:
-                    Exception("Multiple return arguments not supported")
-                d.callback(args[0])
-
-        def _errback(failure_):
-            d.errback(failure.Failure(failure_))
-
-        kwargs["callback"] = _callback
-        kwargs["errback"] = _errback
-        getattr(self.bridge, method_name)(*args, **kwargs)
-        return d
-
-    def action_new_handler(
-        self,
-        action_data_s: str,
-        action_id: str,
-        security_limit: int,
-        profile: str
-    ) -> None:
-        if security_limit > C.SECURITY_LIMIT:
-            log.debug(
-                f"ignoring action {action_id} due to security limit"
-            )
-        else:
-            self.on_signal(
-                "action_new", action_data_s, action_id, security_limit, profile
-            )
-
-    def on_signal(self, signal_name, *args):
-        profile = args[-1]
-        if not profile:
-            log.error(f"got signal without profile: {signal_name}, {args}")
-            return
-        session_iface.WebSession.send(
-            profile,
-            "bridge",
-            {"signal": signal_name, "args": args}
-        )
-
-    def application_started_handler(
-        self,
-        name: str,
-        instance_id: str,
-        extra_s: str
-    ) -> None:
-        callback = self.apps_cb.pop(instance_id, None)
-        if callback is not None:
-            defer.ensureDeferred(callback(str(name), str(instance_id)))
-
-    def application_error_handler(
-        self,
-        name: str,
-        instance_id: str,
-        extra_s: str
-    ) -> None:
-        callback = self.apps_cb.pop(instance_id, None)
-        if callback is not None:
-            extra = data_format.deserialise(extra_s)
-            log.error(
-                f"Can't start application {name}: {extra['class']}\n{extra['msg']}"
-            )
-
-    async def _logged(self, profile, request):
-        """Set everything when a user just logged in
-
-        @param profile
-        @param request
-        @return: a constant indicating the state:
-            - C.PROFILE_LOGGED
-            - C.PROFILE_LOGGED_EXT_JID
-        @raise exceptions.ConflictError: session is already active
-        """
-        register_with_ext_jid = self.waiting_profiles.get_register_with_ext_jid(profile)
-        self.waiting_profiles.purge_request(profile)
-        session = request.getSession()
-        web_session = session_iface.IWebSession(session)
-        if web_session.profile:
-            log.error(_("/!\\ Session has already a profile, this should NEVER happen!"))
-            raise failure.Failure(exceptions.ConflictError("Already active"))
-
-        # XXX: we force string because python D-Bus has its own string type (dbus.String)
-        #   which may cause trouble when exposing it to scripts
-        web_session.profile = str(profile)
-        self.prof_connected.add(profile)
-        cache_dir = os.path.join(
-            self.cache_root_dir, "profiles", regex.path_escape(profile)
-        )
-        # FIXME: would be better to have a global /cache URL which redirect to
-        #        profile's cache directory, without uuid
-        self.cache_resource.putChild(web_session.uuid.encode('utf-8'),
-                                     ProtectedFile(cache_dir))
-        log.debug(
-            _("profile cache resource added from {uuid} to {path}").format(
-                uuid=web_session.uuid, path=cache_dir
-            )
-        )
-
-        def on_expire():
-            log.info("Session expired (profile={profile})".format(profile=profile))
-            self.cache_resource.delEntity(web_session.uuid.encode('utf-8'))
-            log.debug(
-                _("profile cache resource {uuid} deleted").format(uuid=web_session.uuid)
-            )
-            web_session.on_expire()
-            if web_session.ws_socket is not None:
-                web_session.ws_socket.close()
-            # and now we disconnect the profile
-            self.bridge_call("disconnect", profile)
-
-        session.notifyOnExpire(on_expire)
-
-        # FIXME: those session infos should be returned by connect or is_connected
-        infos = await self.bridge_call("session_infos_get", profile)
-        web_session.jid = jid.JID(infos["jid"])
-        own_bare_jid_s = web_session.jid.userhost()
-        own_id_raw = await self.bridge_call(
-            "identity_get", own_bare_jid_s, [], True, profile)
-        web_session.identities[own_bare_jid_s] = data_format.deserialise(own_id_raw)
-        web_session.backend_started = int(infos["started"])
-
-        state = C.PROFILE_LOGGED_EXT_JID if register_with_ext_jid else C.PROFILE_LOGGED
-        return state
-
-    @defer.inlineCallbacks
-    def connect(self, request, login, password):
-        """log user in
-
-        If an other user was already logged, it will be unlogged first
-        @param request(server.Request): request linked to the session
-        @param login(unicode): user login
-            can be profile name
-            can be profile@[libervia_domain.ext]
-            can be a jid (a new profile will be created with this jid if needed)
-        @param password(unicode): user password
-        @return (unicode, None): C.SESSION_ACTIVE: if session was aleady active else
-            self._logged value
-        @raise exceptions.DataError: invalid login
-        @raise exceptions.ProfileUnknownError: this login doesn't exist
-        @raise exceptions.PermissionError: a login is not accepted (e.g. empty password
-            not allowed)
-        @raise exceptions.NotReady: a profile connection is already waiting
-        @raise exceptions.TimeoutError: didn't received and answer from bridge
-        @raise exceptions.InternalError: unknown error
-        @raise ValueError(C.PROFILE_AUTH_ERROR): invalid login and/or password
-        @raise ValueError(C.XMPP_AUTH_ERROR): invalid XMPP account password
-        """
-
-        # XXX: all security checks must be done here, even if present in javascript
-        if login.startswith("@"):
-            raise failure.Failure(exceptions.DataError("No profile_key allowed"))
-
-        if login.startswith("guest@@") and login.count("@") == 2:
-            log.debug("logging a guest account")
-        elif "@" in login:
-            if login.count("@") != 1:
-                raise failure.Failure(
-                    exceptions.DataError("Invalid login: {login}".format(login=login))
-                )
-            try:
-                login_jid = jid.JID(login)
-            except (RuntimeError, jid.InvalidFormat, AttributeError):
-                raise failure.Failure(exceptions.DataError("No profile_key allowed"))
-
-            # FIXME: should it be cached?
-            new_account_domain = yield self.bridge_call("account_domain_new_get")
-
-            if login_jid.host == new_account_domain:
-                # redirect "user@libervia.org" to the "user" profile
-                login = login_jid.user
-                login_jid = None
-        else:
-            login_jid = None
-
-        try:
-            profile = yield self.bridge_call("profile_name_get", login)
-        except Exception:  # XXX: ProfileUnknownError wouldn't work, it's encapsulated
-            # FIXME: find a better way to handle bridge errors
-            if (
-                login_jid is not None and login_jid.user
-            ):  # try to create a new sat profile using the XMPP credentials
-                if not self.options["allow_registration"]:
-                    log.warning(
-                        "Trying to register JID account while registration is not "
-                        "allowed")
-                    raise failure.Failure(
-                        exceptions.DataError(
-                            "JID login while registration is not allowed"
-                        )
-                    )
-                profile = login  # FIXME: what if there is a resource?
-                connect_method = "credentials_xmpp_connect"
-                register_with_ext_jid = True
-            else:  # non existing username
-                raise failure.Failure(exceptions.ProfileUnknownError())
-        else:
-            if profile != login or (
-                not password
-                and profile
-                not in self.options["empty_password_allowed_warning_dangerous_list"]
-            ):
-                # profiles with empty passwords are restricted to local frontends
-                raise exceptions.PermissionError
-            register_with_ext_jid = False
-
-            connect_method = "connect"
-
-        # we check if there is not already an active session
-        web_session = session_iface.IWebSession(request.getSession())
-        if web_session.profile:
-            # yes, there is
-            if web_session.profile != profile:
-                # it's a different profile, we need to disconnect it
-                log.warning(_(
-                    "{new_profile} requested login, but {old_profile} was already "
-                    "connected, disconnecting {old_profile}").format(
-                        old_profile=web_session.profile, new_profile=profile))
-                self.purge_session(request)
-
-        if self.waiting_profiles.get_request(profile):
-            #  FIXME: check if and when this can happen
-            raise failure.Failure(exceptions.NotReady("Already waiting"))
-
-        self.waiting_profiles.set_request(request, profile, register_with_ext_jid)
-        try:
-            connected = yield self.bridge_call(connect_method, profile, password)
-        except Exception as failure_:
-            fault = getattr(failure_, 'classname', None)
-            self.waiting_profiles.purge_request(profile)
-            if fault in ("PasswordError", "ProfileUnknownError"):
-                log.info("Profile {profile} doesn't exist or the submitted password is "
-                         "wrong".format( profile=profile))
-                raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR))
-            elif fault == "SASLAuthError":
-                log.info("The XMPP password of profile {profile} is wrong"
-                    .format(profile=profile))
-                raise failure.Failure(ValueError(C.XMPP_AUTH_ERROR))
-            elif fault == "NoReply":
-                log.info(_("Did not receive a reply (the timeout expired or the "
-                           "connection is broken)"))
-                raise exceptions.TimeOutError
-            elif fault is None:
-                log.info(_("Unexepected failure: {failure_}").format(failure_=failure))
-                raise failure_
-            else:
-                log.error('Unmanaged fault class "{fault}" in errback for the '
-                          'connection of profile {profile}'.format(
-                              fault=fault, profile=profile))
-                raise failure.Failure(exceptions.InternalError(fault))
-
-        if connected:
-            #  profile is already connected in backend
-            # do we have a corresponding session in Libervia?
-            web_session = session_iface.IWebSession(request.getSession())
-            if web_session.profile:
-                # yes, session is active
-                if web_session.profile != profile:
-                    # existing session should have been ended above
-                    # so this line should never be reached
-                    log.error(_(
-                        "session profile [{session_profile}] differs from login "
-                        "profile [{profile}], this should not happen!")
-                            .format(session_profile=web_session.profile, profile=profile))
-                    raise exceptions.InternalError("profile mismatch")
-                defer.returnValue(C.SESSION_ACTIVE)
-            log.info(
-                _(
-                    "profile {profile} was already connected in backend".format(
-                        profile=profile
-                    )
-                )
-            )
-            #  no, we have to create it
-
-        state = yield defer.ensureDeferred(self._logged(profile, request))
-        defer.returnValue(state)
-
-    def register_new_account(self, request, login, password, email):
-        """Create a new account, or return error
-        @param request(server.Request): request linked to the session
-        @param login(unicode): new account requested login
-        @param email(unicode): new account email
-        @param password(unicode): new account password
-        @return(unicode): a constant indicating the state:
-            - C.BAD_REQUEST: something is wrong in the request (bad arguments)
-            - C.INVALID_INPUT: one of the data is not valid
-            - C.REGISTRATION_SUCCEED: new account has been successfully registered
-            - C.ALREADY_EXISTS: the given profile already exists
-            - C.INTERNAL_ERROR or any unmanaged fault string
-        @raise PermissionError: registration is now allowed in server configuration
-        """
-        if not self.options["allow_registration"]:
-            log.warning(
-                _("Registration received while it is not allowed, hack attempt?")
-            )
-            raise failure.Failure(
-                exceptions.PermissionError("Registration is not allowed on this server")
-            )
-
-        if (
-            not re.match(C.REG_LOGIN_RE, login)
-            or not re.match(C.REG_EMAIL_RE, email, re.IGNORECASE)
-            or len(password) < C.PASSWORD_MIN_LENGTH
-        ):
-            return C.INVALID_INPUT
-
-        def registered(result):
-            return C.REGISTRATION_SUCCEED
-
-        def registering_error(failure_):
-            # FIXME: better error handling for bridge error is needed
-            status = failure_.value.fullname.split('.')[-1]
-            if status == "ConflictError":
-                return C.ALREADY_EXISTS
-            elif status == "InvalidCertificate":
-                return C.INVALID_CERTIFICATE
-            elif status == "InternalError":
-                return C.INTERNAL_ERROR
-            else:
-                log.error(
-                    _("Unknown registering error status: {status}\n{traceback}").format(
-                        status=status, traceback=failure_.value.message
-                    )
-                )
-                return status
-
-        d = self.bridge_call("libervia_account_register", email, password, login)
-        d.addCallback(registered)
-        d.addErrback(registering_error)
-        return d
-
-    def addCleanup(self, callback, *args, **kwargs):
-        """Add cleaning method to call when service is stopped
-
-        cleaning method will be called in reverse order of they insertion
-        @param callback: callable to call on service stop
-        @param *args: list of arguments of the callback
-        @param **kwargs: list of keyword arguments of the callback"""
-        self._cleanup.insert(0, (callback, args, kwargs))
-
-    def init_eb(self, failure):
-        from twisted.application import app
-        if failure.check(SysExit):
-            if failure.value.message:
-                log.error(failure.value.message)
-            app._exitCode = failure.value.exit_code
-            reactor.stop()
-        else:
-            log.error(_("Init error: {msg}").format(msg=failure))
-            app._exitCode = C.EXIT_INTERNAL_ERROR
-            reactor.stop()
-            return failure
-
-    def _build_only_cb(self, __):
-        log.info(_("Stopping here due to --build-only flag"))
-        self.stop()
-
-    def startService(self):
-        """Connect the profile for Libervia and start the HTTP(S) server(s)"""
-        self._init()
-        if self.options['build-only']:
-            self.initialised.addCallback(self._build_only_cb)
-        else:
-            self.initialised.addCallback(self._start_service)
-        self.initialised.addErrback(self.init_eb)
-
-    ## URLs ##
-
-    def put_child_sat(self, path, resource):
-        """Add a child to the sat resource"""
-        if not isinstance(path, bytes):
-            raise ValueError("path must be specified in bytes")
-        self.sat_root.putChild(path, resource)
-
-    def put_child_all(self, path, resource):
-        """Add a child to all vhost root resources"""
-        if not isinstance(path, bytes):
-            raise ValueError("path must be specified in bytes")
-        # we wrap before calling putChild, to avoid having useless multiple instances
-        # of the resource
-        # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders)
-        wrapped_res = web_resource.EncodingResourceWrapper(
-            resource, [server.GzipEncoderFactory()])
-        for root in self.roots:
-            root.putChild(path, wrapped_res)
-
-    def get_build_path(self, site_name: str, dev: bool=False) -> Path:
-        """Generate build path for a given site name
-
-        @param site_name: name of the site
-        @param dev: return dev build dir if True, production one otherwise
-            dev build dir is used for installing dependencies needed temporarily (e.g.
-            to compile files), while production build path is the one served by the
-            HTTP server, where final files are downloaded.
-        @return: path to the build directory
-        """
-        sub_dir = C.DEV_BUILD_DIR if dev else C.PRODUCTION_BUILD_DIR
-        build_path_elts = [
-            config.config_get(self.main_conf, "", "local_dir"),
-            C.CACHE_DIR,
-            C.LIBERVIA_CACHE,
-            sub_dir,
-            regex.path_escape(site_name or C.SITE_NAME_DEFAULT)]
-        build_path = Path("/".join(build_path_elts))
-        return build_path.expanduser().resolve()
-
-    def get_ext_base_url_data(self, request):
-        """Retrieve external base URL Data
-
-        this method try to retrieve the base URL found by external user
-        It does by checking in this order:
-            - base_url_ext option from configuration
-            - proxy x-forwarder-host headers
-            - URL of the request
-        @return (urlparse.SplitResult): SplitResult instance with only scheme and
-            netloc filled
-        """
-        ext_data = self.base_url_ext_data
-        url_path = request.URLPath()
-
-        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:
-            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(),
-            ext_data.netloc or proxy_netloc or url_path.netloc.decode(),
-            ext_data.path or "/",
-            "",
-            "",
-        )
-
-    def get_ext_base_url(
-            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: same as for urlsplit.urlsplit
-            path will be prefixed to follow found external URL if suitable
-        @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.get_ext_base_url_data(request)
-        return urllib.parse.urlunsplit(
-            (
-                split_result.scheme if scheme is None else scheme,
-                split_result.netloc,
-                os.path.join(split_result.path, path),
-                query,
-                fragment,
-            )
-        )
-
-    def check_redirection(self, vhost_root: LiberviaRootResource, url_path: str) -> str:
-        """check is a part of the URL prefix is redirected then replace it
-
-        @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_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_path
-
-    ## Sessions ##
-
-    def purge_session(self, request):
-        """helper method to purge a session during request handling"""
-        session = request.session
-        if session is not None:
-            log.debug(_("session purge"))
-            web_session = self.get_session_data(request, session_iface.IWebSession)
-            socket = web_session.ws_socket
-            if socket is not None:
-                socket.close()
-                session.ws_socket = None
-            session.expire()
-            # FIXME: not clean but it seems that it's the best way to reset
-            #        session during request handling
-            request._secureSession = request._insecureSession = None
-
-    def get_session_data(self, request, *args):
-        """helper method to retrieve session data
-
-        @param request(server.Request): request linked to the session
-        @param *args(zope.interface.Interface): interface of the session to get
-        @return (iterator(data)): requested session data
-        """
-        session = request.getSession()
-        if len(args) == 1:
-            return args[0](session)
-        else:
-            return (iface(session) for iface in args)
-
-    @defer.inlineCallbacks
-    def get_affiliation(self, request, service, node):
-        """retrieve pubsub node affiliation for current user
-
-        use cache first, and request pubsub service if not cache is found
-        @param request(server.Request): request linked to the session
-        @param service(jid.JID): pubsub service
-        @param node(unicode): pubsub node
-        @return (unicode): affiliation
-        """
-        web_session = self.get_session_data(request, session_iface.IWebSession)
-        if web_session.profile is None:
-            raise exceptions.InternalError("profile must be set to use this method")
-        affiliation = web_session.get_affiliation(service, node)
-        if affiliation is not None:
-            defer.returnValue(affiliation)
-        else:
-            try:
-                affiliations = yield self.bridge_call(
-                    "ps_affiliations_get", service.full(), node, web_session.profile
-                )
-            except Exception as e:
-                log.warning(
-                    "Can't retrieve affiliation for {service}/{node}: {reason}".format(
-                        service=service, node=node, reason=e
-                    )
-                )
-                affiliation = ""
-            else:
-                try:
-                    affiliation = affiliations[node]
-                except KeyError:
-                    affiliation = ""
-            web_session.set_affiliation(service, node, affiliation)
-            defer.returnValue(affiliation)
-
-    ## Websocket (dynamic pages) ##
-
-    def get_websocket_url(self, request):
-        base_url_split = self.get_ext_base_url_data(request)
-        if base_url_split.scheme.endswith("s"):
-            scheme = "wss"
-        else:
-            scheme = "ws"
-
-        return self.get_ext_base_url(request, path=scheme, scheme=scheme)
-
-
-    ## Various utils ##
-
-    def get_http_date(self, timestamp=None):
-        now = time.gmtime(timestamp)
-        fmt_date = "{day_name}, %d {month_name} %Y %H:%M:%S GMT".format(
-            day_name=C.HTTP_DAYS[now.tm_wday], month_name=C.HTTP_MONTH[now.tm_mon - 1]
-        )
-        return time.strftime(fmt_date, now)
-
-    ## service management ##
-
-    def _start_service(self, __=None):
-        """Actually start the HTTP(S) server(s) after the profile for Libervia is connected.
-
-        @raise ImportError: OpenSSL is not available
-        @raise IOError: the certificate file doesn't exist
-        @raise OpenSSL.crypto.Error: the certificate file is invalid
-        """
-        # now that we have service profile connected, we add resource for its cache
-        service_path = regex.path_escape(C.SERVICE_PROFILE)
-        cache_dir = os.path.join(self.cache_root_dir, "profiles", service_path)
-        self.cache_resource.putChild(service_path.encode('utf-8'),
-                                     ProtectedFile(cache_dir))
-        self.service_cache_url = "/" + os.path.join(C.CACHE_DIR, service_path)
-        session_iface.WebSession.service_cache_url = self.service_cache_url
-
-        if self.options["connection_type"] in ("https", "both"):
-            try:
-                tls.tls_options_check(self.options)
-                context_factory = tls.get_tls_context_factory(self.options)
-            except exceptions.ConfigError as e:
-                log.warning(
-                    f"There is a problem in TLS settings in your configuration file: {e}")
-                self.quit(2)
-            except exceptions.DataError as e:
-                log.warning(
-                    f"Can't set TLS: {e}")
-                self.quit(1)
-            reactor.listenSSL(self.options["port_https"], self.site, context_factory)
-        if self.options["connection_type"] in ("http", "both"):
-            if (
-                self.options["connection_type"] == "both"
-                and self.options["redirect_to_https"]
-            ):
-                reactor.listenTCP(
-                    self.options["port"],
-                    server.Site(
-                        RedirectToHTTPS(
-                            self.options["port"], self.options["port_https_ext"]
-                        )
-                    ),
-                )
-            else:
-                reactor.listenTCP(self.options["port"], self.site)
-
-    @defer.inlineCallbacks
-    def stopService(self):
-        log.info(_("launching cleaning methods"))
-        for callback, args, kwargs in self._cleanup:
-            callback(*args, **kwargs)
-        try:
-            yield self.bridge_call("disconnect", C.SERVICE_PROFILE)
-        except Exception:
-            log.warning("Can't disconnect service profile")
-
-    def run(self):
-        reactor.run()
-
-    def stop(self):
-        reactor.stop()
-
-    def quit(self, exit_code=None):
-        """Exit app when reactor is running
-
-        @param exit_code(None, int): exit code
-        """
-        self.stop()
-        sys.exit(exit_code or 0)
-
-
-class RedirectToHTTPS(web_resource.Resource):
-    def __init__(self, old_port, new_port):
-        web_resource.Resource.__init__(self)
-        self.isLeaf = True
-        self.old_port = old_port
-        self.new_port = new_port
-
-    def render(self, request):
-        netloc = request.URLPath().netloc.decode().replace(
-            f":{self.old_port}", f":{self.new_port}"
-        )
-        url = f"https://{netloc}{request.uri.decode()}"
-        return web_util.redirectTo(url.encode(), request)
-
-
-registerAdapter(session_iface.WebSession, server.Session, session_iface.IWebSession)
-registerAdapter(
-    session_iface.SATGuestSession, server.Session, session_iface.ISATGuestSession
-)
--- a/libervia/server/session_iface.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,289 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a SàT frontend
-# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-from collections import OrderedDict, deque
-import os.path
-import time
-from typing import List, Dict, Optional
-
-import shortuuid
-from zope.interface import Attribute, Interface
-from zope.interface import implementer
-
-from sat.core.log import getLogger
-
-from libervia.server.classes import Notification
-from libervia.server.constants import Const as C
-
-
-log = getLogger(__name__)
-
-FLAGS_KEY = "_flags"
-NOTIFICATIONS_KEY = "_notifications"
-MAX_CACHE_AFFILIATIONS = 100  # number of nodes to keep in cache
-
-
-class IWebSession(Interface):
-    profile = Attribute("Sat profile")
-    jid = Attribute("JID associated with the profile")
-    uuid = Attribute("uuid associated with the profile session")
-    identities = Attribute("Identities of XMPP entities")
-
-
-@implementer(IWebSession)
-class WebSession:
-    profiles_map: Dict[Optional[str], List["WebSession"]] = {}
-
-    def __init__(self, session):
-        self._profile = None
-        self.jid = None
-        self.started = time.time()
-        # time when the backend session was started
-        self.backend_started = None
-        self.uuid = str(shortuuid.uuid())
-        self.identities = {}
-        self.csrf_token = str(shortuuid.uuid())
-        self.ws_token = str(shortuuid.uuid())
-        self.ws_socket = None
-        self.ws_buffer = deque(maxlen=200)
-        self.locale = None  # i18n of the pages
-        self.theme = None
-        self.pages_data = {}  # used to keep data accross reloads (key is page instance)
-        self.affiliations = OrderedDict()  # cache for node affiliations
-        self.profiles_map.setdefault(C.SERVICE_PROFILE, []).append(self)
-        log.debug(
-            f"session started for {C.SERVICE_PROFILE} "
-            f"({len(self.get_profile_sessions(C.SERVICE_PROFILE))} session(s) active)"
-        )
-
-    @property
-    def profile(self) -> Optional[str]:
-        return self._profile
-
-    @profile.setter
-    def profile(self, profile: Optional[str]) -> None:
-        old_profile = self._profile or C.SERVICE_PROFILE
-        new_profile = profile or C.SERVICE_PROFILE
-        try:
-            self.profiles_map[old_profile].remove(self)
-        except (ValueError, KeyError):
-            log.warning(f"session was not registered for profile {old_profile}")
-        else:
-            nb_session_old = len(self.get_profile_sessions(old_profile))
-            log.debug(f"{old_profile} has now {nb_session_old} session(s) active")
-
-        self._profile = profile
-        self.profiles_map.setdefault(new_profile, []).append(self)
-        nb_session_new = len(self.get_profile_sessions(new_profile))
-        log.debug(f"{new_profile} has now {nb_session_new} session(s) active")
-
-    @property
-    def cache_dir(self):
-        if self.profile is None:
-            return self.service_cache_url + "/"
-        return os.path.join("/", C.CACHE_DIR, self.uuid) + "/"
-
-    @property
-    def connected(self):
-        return self.profile is not None
-
-    @property
-    def guest(self):
-        """True if this is a guest session"""
-        if self.profile is None:
-            return False
-        else:
-            return self.profile.startswith("guest@@")
-
-    @classmethod
-    def send(cls, profile: str, data_type: str, data: dict) -> None:
-        """Send a message to all session
-
-        If the session doesn't have an active websocket, the message is buffered until a
-        socket is available
-        """
-        for session in cls.profiles_map.get(profile, []):
-            if session.ws_socket is None or not session.ws_socket.init_ok:
-                session.ws_buffer.append({"data_type": data_type, "data": data})
-            else:
-                session.ws_socket.send(data_type, data)
-
-    def on_expire(self) -> None:
-        profile = self._profile or C.SERVICE_PROFILE
-        try:
-            self.profiles_map[profile].remove(self)
-        except (ValueError, KeyError):
-            log.warning(f"session was not registered for profile {profile}")
-        else:
-            nb_session = len(self.get_profile_sessions(profile))
-            log.debug(
-                f"Session for profile {profile} expired. {profile} has now {nb_session} "
-                f"session(s) active."
-            )
-
-    @classmethod
-    def get_profile_sessions(cls, profile: str) -> List["WebSession"]:
-        return cls.profiles_map.get(profile, [])
-
-    def get_page_data(self, page, key):
-        """get session data for a page
-
-        @param page(LiberviaPage): instance of the page
-        @param key(object): data key
-        return (None, object): value of the key
-            None if not found or page_data doesn't exist
-        """
-        return self.pages_data.get(page, {}).get(key)
-
-    def pop_page_data(self, page, key, default=None):
-        """like get_page_data, but remove key once value is gotten
-
-        @param page(LiberviaPage): instance of the page
-        @param key(object): data key
-        @param default(object): value to return if key is not found
-        @return (object): found value or default
-        """
-        page_data = self.pages_data.get(page)
-        if page_data is None:
-            return default
-        value = page_data.pop(key, default)
-        if not page_data:
-            # no need to keep unused page_data
-            del self.pages_data[page]
-        return value
-
-    def set_page_data(self, page, key, value):
-        """set data to persist on reload
-
-        @param page(LiberviaPage): instance of the page
-        @param key(object): data key
-        @param value(object): value to set
-        @return (object): set value
-        """
-        page_data = self.pages_data.setdefault(page, {})
-        page_data[key] = value
-        return value
-
-    def set_page_flag(self, page, flag):
-        """set a flag for this page
-
-        @param page(LiberviaPage): instance of the page
-        @param flag(unicode): flag to set
-        """
-        flags = self.get_page_data(page, FLAGS_KEY)
-        if flags is None:
-            flags = self.set_page_data(page, FLAGS_KEY, set())
-        flags.add(flag)
-
-    def pop_page_flag(self, page, flag):
-        """return True if flag is set
-
-        flag is removed if it was set
-        @param page(LiberviaPage): instance of the page
-        @param flag(unicode): flag to set
-        @return (bool): True if flaag was set
-        """
-        page_data = self.pages_data.get(page, {})
-        flags = page_data.get(FLAGS_KEY)
-        if flags is None:
-            return False
-        if flag in flags:
-            flags.remove(flag)
-            # we remove data if they are not used anymore
-            if not flags:
-                del page_data[FLAGS_KEY]
-            if not page_data:
-                del self.pages_data[page]
-            return True
-        else:
-            return False
-
-    def set_page_notification(self, page, message, level=C.LVL_INFO):
-        """set a flag for this page
-
-        @param page(LiberviaPage): instance of the page
-        @param flag(unicode): flag to set
-        """
-        notif = Notification(message, level)
-        notifs = self.get_page_data(page, NOTIFICATIONS_KEY)
-        if notifs is None:
-            notifs = self.set_page_data(page, NOTIFICATIONS_KEY, [])
-        notifs.append(notif)
-
-    def pop_page_notifications(self, page):
-        """Return and remove last page notification
-
-        @param page(LiberviaPage): instance of the page
-        @return (list[Notification]): notifications if any
-        """
-        page_data = self.pages_data.get(page, {})
-        notifs = page_data.get(NOTIFICATIONS_KEY)
-        if not notifs:
-            return []
-        ret = notifs[:]
-        del notifs[:]
-        return ret
-
-    def get_affiliation(self, service, node):
-        """retrieve affiliation for a pubsub node
-
-        @param service(jid.JID): pubsub service
-        @param node(unicode): pubsub node
-        @return (unicode, None): affiliation, or None if it is not in cache
-        """
-        if service.resource:
-            raise ValueError("Service must not have a resource")
-        if not node:
-            raise ValueError("node must be set")
-        try:
-            affiliation = self.affiliations.pop((service, node))
-        except KeyError:
-            return None
-        else:
-            # we replace at the top to get the most recently used on top
-            # so less recently used will be removed if cache is full
-            self.affiliations[(service, node)] = affiliation
-            return affiliation
-
-    def set_affiliation(self, service, node, affiliation):
-        """cache affiliation for a node
-
-        will empty cache when it become too big
-        @param service(jid.JID): pubsub service
-        @param node(unicode): pubsub node
-        @param affiliation(unicode): affiliation to this node
-        """
-        if service.resource:
-            raise ValueError("Service must not have a resource")
-        if not node:
-            raise ValueError("node must be set")
-        self.affiliations[(service, node)] = affiliation
-        while len(self.affiliations) > MAX_CACHE_AFFILIATIONS:
-            self.affiliations.popitem(last=False)
-
-
-class ISATGuestSession(Interface):
-    id = Attribute("UUID of the guest")
-    data = Attribute("data associated with the guest")
-
-
-@implementer(ISATGuestSession)
-class SATGuestSession(object):
-
-    def __init__(self, session):
-        self.id = None
-        self.data = None
--- a/libervia/server/tasks/implicit/task_brython.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,149 +0,0 @@
-#!/ur/bin/env python3
-
-from ast import literal_eval
-import json
-from pathlib import Path
-import shutil
-from typing import Any, Dict
-
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.tools.common import utils
-
-from libervia.server.classes import Script
-from libervia.server.constants import Const as C
-from libervia.server.tasks import task
-
-
-log = getLogger(__name__)
-
-
-class Task(task.Task):
-
-    def prepare(self):
-        if "brython" not in self.resource.browser_modules:
-            raise exceptions.CancelError("No brython module found")
-
-        brython_js = self.build_path / "brython.js"
-        if not brython_js.is_file():
-            installed_ver = None
-        else:
-            with brython_js.open() as f:
-                for line in f:
-                    if line.startswith('// implementation ['):
-                        installed_ver = literal_eval(line[18:])[:3]
-                        log.debug(
-                            f"brython v{'.'.join(str(v) for v in installed_ver)} already "
-                            f"installed")
-                        break
-                else:
-                    log.warning(
-                        f"brython file at {brython_js} doesn't has implementation "
-                        f"version"
-                    )
-                    installed_ver = None
-
-        try:
-            import brython
-            try:
-                from brython.__main__ import implementation
-            except ImportError:
-                from brython.version import implementation
-        except ModuleNotFoundError as e:
-            log.error('"brython" module is missing, can\'t use browser code for Brython')
-            raise e
-        ver = [int(v) for v in implementation.split('.')[:3]]
-        if ver != installed_ver:
-            log.info(_("Installing Brython v{version}").format(
-                version='.'.join(str(v) for v in ver)))
-            data_path = Path(brython.__file__).parent / 'data'
-            # shutil has blocking method, but the task is run before we start
-            # the web server, so it's not a big deal
-            shutil.copyfile(data_path / "brython.js", brython_js)
-            shutil.copy(data_path / "brython_stdlib.js", self.build_path)
-        else:
-            log.debug("Brython is already installed")
-
-        self.WATCH_DIRS = []
-        self.set_common_scripts()
-
-    def set_common_scripts(self):
-        for dyn_data in self.resource.browser_modules["brython"]:
-            url_hash = dyn_data['url_hash']
-            import_url = f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}"
-            dyn_data.setdefault('scripts', utils.OrderedSet()).update([
-                Script(src=f"/{C.BUILD_DIR}/brython.js"),
-                Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"),
-            ])
-            dyn_data.setdefault('template', {})['body_onload'] = self.get_body_onload(
-                extra_path=[import_url])
-            self.WATCH_DIRS.append(dyn_data['path'].resolve())
-
-    def get_body_onload(self, debug=True, cache=True, extra_path=None):
-        on_load_opts: Dict[str, Any]  = {"pythonpath": [f"/{C.BUILD_DIR}"]}
-        if debug:
-            on_load_opts["debug"] = 1
-        if cache:
-            on_load_opts["cache"] = True
-        if extra_path is not None:
-            on_load_opts["pythonpath"].extend(extra_path)
-
-        return f"brython({json.dumps(on_load_opts)})"
-
-    def copy_files(self, files_paths, dest):
-        for p in files_paths:
-            log.debug(f"copying {p}")
-            if p.is_dir():
-                if p.name == '__pycache__':
-                    continue
-                shutil.copytree(p, dest / p.name)
-            else:
-                shutil.copy(p, dest)
-
-    async def on_dir_event(self, host, filepath, flags):
-        self.set_common_scripts()
-        await self.manager.run_task_instance(self)
-
-    def start(self):
-        dyn_path = self.build_path / C.BUILD_DIR_DYN
-        for dyn_data in self.resource.browser_modules["brython"]:
-            url_hash = dyn_data['url_hash']
-            if url_hash is None:
-                # root modules
-                url_prefix = dyn_data.get('url_prefix')
-                if url_prefix is None:
-                    dest = self.build_path
-                    init_dest_url = f"/{C.BUILD_DIR}/__init__.py"
-                else:
-                    dest = self.build_path / url_prefix
-                    dest.mkdir(exist_ok = True)
-                    init_dest_url = f"/{C.BUILD_DIR}/{url_prefix}/__init__.py"
-
-                self.copy_files(dyn_data['path'].glob('*py'), dest)
-
-                init_file = dyn_data['path'] / '__init__.py'
-                if init_file.is_file():
-                    self.resource.dyn_data_common['scripts'].update([
-                        Script(src=f"/{C.BUILD_DIR}/brython.js"),
-                        Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"),
-                        Script(type='text/python', src=init_dest_url)
-                    ])
-                    self.resource.dyn_data_common.setdefault(
-                        "template", {})['body_onload'] = self.get_body_onload()
-            else:
-                page_dyn_path = dyn_path / url_hash
-                log.debug(f"using dynamic path at {page_dyn_path}")
-                if page_dyn_path.exists():
-                    log.debug("cleaning existing path")
-                    shutil.rmtree(page_dyn_path)
-
-                page_dyn_path.mkdir(parents=True, exist_ok=True)
-                log.debug("copying browser python files")
-                self.copy_files(dyn_data['path'].iterdir(), page_dyn_path)
-
-                script = Script(
-                    type='text/python',
-                    src=f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}/__init__.py"
-                )
-                dyn_data.setdefault('scripts', utils.OrderedSet()).add(script)
--- a/libervia/server/tasks/implicit/task_js_modules.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-#!/ur/bin/env python3
-
-import json
-from pathlib import Path
-from sat.core.i18n import _
-from sat.core.log import getLogger
-from sat.core import exceptions
-from libervia.server.constants import Const as C
-from libervia.server.tasks import task
-
-
-log = getLogger(__name__)
-
-
-class Task(task.Task):
-
-    async def prepare(self):
-        if "js" not in self.resource.browser_modules:
-            raise exceptions.CancelError("No JS module needed")
-
-    async def start(self):
-        js_data = self.resource.browser_modules['js']
-        package = js_data.get('package', {})
-        package_path = self.build_path / 'package.json'
-        with package_path.open('w') as f:
-            json.dump(package, f)
-
-        cmd = self.find_command('yarnpkg', 'yarn')
-        await self.runCommand(cmd, 'install', path=str(self.build_path))
-
-        try:
-            brython_map = js_data['brython_map']
-        except KeyError:
-            pass
-        else:
-            log.info(_("creating JS modules mapping for Brython"))
-            js_modules_path = self.build_path / 'js_modules'
-            js_modules_path.mkdir(exist_ok=True)
-            init_path = js_modules_path / '__init__.py'
-            init_path.touch()
-
-            for module_name, module_data in brython_map.items():
-                log.debug(f"generating mapping for {module_name}")
-                if ' ' in module_name:
-                    raise ValueError(
-                        f"module {module_name!r} has space(s), it must not!")
-                module_path = js_modules_path / f"{module_name}.py"
-                if isinstance(module_data, str):
-                    module_data = {'path': module_data}
-                try:
-                    js_path = module_data.pop('path')
-                except KeyError:
-                    raise ValueError(
-                        f'module data for {module_name} must have a "path" key')
-                module_data['path'] = Path('node_modules') / js_path.strip(' /')
-                export = module_data.get('export') or [module_name]
-                export_objects = '\n'.join(f'{e} = window.{e}' for e in export)
-                extra_kwargs = {"build_dir": C.BUILD_DIR}
-
-                with module_path.open('w') as f:
-                    f.write(f"""\
-#!/usr/bin/env python3
-from browser import window, load
-{module_data.get('extra_import', '')}
-
-load("{Path('/').joinpath(C.BUILD_DIR, module_data['path'])}")
-{export_objects}
-{module_data.get('extra_init', '').format(**extra_kwargs)}
-""")
--- a/libervia/server/tasks/implicit/task_sass.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,69 +0,0 @@
-#!/ur/bin/env python3
-
-import json
-from sat.core.log import getLogger
-from sat.core import exceptions
-from libervia.server.tasks import task
-
-
-log = getLogger(__name__)
-
-SASS_SUFFIXES = ('.sass', '.scss')
-
-
-class Task(task.Task):
-    """Compile .sass and .scss files found in themes browser paths"""
-    AFTER = ['js_modules']
-
-    async def prepare(self):
-        # we look for any Sass file, and cancel this task if none is found
-        sass_dirs = set()
-        for browser_path in self.resource.browser_modules.get('themes_browser_paths', []):
-            for p in browser_path.iterdir():
-                if p.suffix in SASS_SUFFIXES:
-                    sass_dirs.add(browser_path)
-                    break
-
-        if not sass_dirs:
-            raise exceptions.CancelError("No Sass file found")
-
-        # we have some Sass files, we need to install the compiler
-        d_path = self.resource.dev_build_path
-        package_path = d_path / "package.json"
-        try:
-            with package_path.open() as f:
-                package = json.load(f)
-        except FileNotFoundError:
-            package = {}
-        except Exception as e:
-            log.error(f"Unexepected exception while parsing package.json: {e}")
-
-        if 'node-sass' not in package.setdefault('dependencies', {}):
-            package['dependencies']['node-sass'] = 'latest'
-            with package_path.open('w') as f:
-                json.dump(package, f, indent=4)
-
-        cmd = self.find_command('yarnpkg', 'yarn')
-        await self.runCommand(cmd, 'install', path=str(d_path))
-
-        self.WATCH_DIRS = list(sass_dirs)
-
-    async def on_dir_event(self, host, filepath, flags):
-        if filepath.suffix in SASS_SUFFIXES:
-            await self.manager.run_task_instance(self)
-
-    async def start(self):
-        d_path = self.resource.dev_build_path
-        node_sass = d_path / 'node_modules' / 'node-sass' / 'bin' / 'node-sass'
-        for browser_path in self.resource.browser_modules['themes_browser_paths']:
-            for p in browser_path.iterdir():
-                if p.suffix not in SASS_SUFFIXES:
-                    continue
-                await self.runCommand(
-                    str(node_sass),
-                    "--omit-source-map-url",
-                    "--output-style", "compressed",
-                    "--output", str(self.build_path),
-                    str(p),
-                    path=str(self.build_path)
-                )
--- a/libervia/server/tasks/manager.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,213 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-import os
-import os.path
-from pathlib import Path
-from typing import Dict
-import importlib.util
-from twisted.internet import defer
-from sat.core.log import getLogger
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.tools import utils
-from libervia.server.constants import Const as C
-from . import implicit
-from .task import Task
-
-log = getLogger(__name__)
-
-DEFAULT_SITE_LABEL = _("default site")
-
-
-class TasksManager:
-    """Handle tasks of a Libervia site"""
-
-    def __init__(self, host, site_resource):
-        """
-        @param site_resource(LiberviaRootResource): root resource of the site to manage
-        """
-        self.host = host
-        self.resource = site_resource
-        self.tasks_dir = self.site_path / C.TASKS_DIR
-        self.tasks = {}
-        self._build_path = None
-        self._current_task = None
-
-    @property
-    def site_path(self):
-        return Path(self.resource.site_path)
-
-    @property
-    def build_path(self):
-        """path where generated files will be build for this site"""
-        if self._build_path is None:
-            self._build_path = self.host.get_build_path(self.site_name)
-        return self._build_path
-
-    @property
-    def site_name(self):
-        return self.resource.site_name
-
-    def validate_data(self, task):
-        """Check workflow attributes in task"""
-
-        for var, allowed in (("ON_ERROR", ("continue", "stop")),
-                             ("LOG_OUTPUT", bool),
-                             ("WATCH_DIRS", list)):
-            value = getattr(task, var)
-
-            if isinstance(allowed, type):
-                if allowed is list and value is None:
-                    continue
-                if not isinstance(value, allowed):
-                    raise ValueError(
-                        _("Unexpected value for {var}, {allowed} is expected.")
-                        .format(var=var, allowed=allowed))
-            else:
-                if not value in allowed:
-                    raise ValueError(_("Unexpected value for {var}: {value!r}").format(
-                        var=var, value=value))
-
-    async def import_task(
-        self,
-        task_name: str,
-        task_path: Path,
-        to_import: Dict[str, Path]
-    ) -> None:
-        if task_name in self.tasks:
-            log.debug(f"skipping task {task_name} which is already imported")
-            return
-        module_name = f"{self.site_name or C.SITE_NAME_DEFAULT}.task.{task_name}"
-
-        spec = importlib.util.spec_from_file_location(module_name, task_path)
-        task_module = importlib.util.module_from_spec(spec)
-        spec.loader.exec_module(task_module)
-        task = task_module.Task(self, task_name)
-        if task.AFTER is not None:
-            for pre_task_name in task.AFTER:
-                log.debug(
-                    f"task {task_name!r} must be run after {pre_task_name!r}")
-                try:
-                    pre_task_path = to_import[pre_task_name]
-                except KeyError:
-                    raise ValueError(
-                        f"task {task_name!r} must be run after {pre_task_name!r}, "
-                        f"however there is no task with such name")
-                await self.import_task(pre_task_name, pre_task_path, to_import)
-
-        # we launch prepare, which is a method used to prepare
-        # data at runtime (e.g. set WATCH_DIRS using config)
-        try:
-            prepare = task.prepare
-        except AttributeError:
-            pass
-        else:
-            log.info(_('== preparing task "{task_name}" for {site_name} =='.format(
-                task_name=task_name, site_name=self.site_name or DEFAULT_SITE_LABEL)))
-            try:
-                await utils.as_deferred(prepare)
-            except exceptions.CancelError as e:
-                log.debug(f"Skipping {task_name} which cancelled itself: {e}")
-                return
-
-        self.tasks[task_name] = task
-        self.validate_data(task)
-        if self.host.options['dev-mode']:
-            dirs = task.WATCH_DIRS or []
-            for dir_ in dirs:
-                self.host.files_watcher.watch_dir(
-                    dir_, auto_add=True, recursive=True,
-                    callback=self._autorun_task, task_name=task_name)
-
-    async def parse_tasks_dir(self, dir_path: Path) -> None:
-        log.debug(f"parsing tasks in {dir_path}")
-        tasks_paths = sorted(dir_path.glob('task_*.py'))
-        to_import = {}
-        for task_path in tasks_paths:
-            if not task_path.is_file():
-                continue
-            task_name = task_path.stem[5:].lower().strip()
-            if not task_name:
-                continue
-            if task_name in self.tasks:
-                raise exceptions.ConflictError(
-                    "A task with the name [{name}] already exists".format(
-                        name=task_name))
-            log.debug(f"task {task_name} found")
-            to_import[task_name] = task_path
-
-        for task_name, task_path in to_import.items():
-            await self.import_task(task_name, task_path, to_import)
-
-    async def parse_tasks(self):
-        # implicit tasks are always run
-        implicit_path = Path(implicit.__file__).parent
-        await self.parse_tasks_dir(implicit_path)
-        # now we check if there are tasks specific to this site
-        if not self.tasks_dir.is_dir():
-            log.debug(_("{name} has no task to launch.").format(
-                name = self.resource.site_name or DEFAULT_SITE_LABEL))
-            return
-        else:
-            await self.parse_tasks_dir(self.tasks_dir)
-
-    def _autorun_task(self, host, filepath, flags, task_name):
-        """Called when an event is received from a watched directory"""
-        if flags == ['create']:
-            return
-        try:
-            task = self.tasks[task_name]
-            on_dir_event_cb = task.on_dir_event
-        except AttributeError:
-            return defer.ensureDeferred(self.run_task(task_name))
-        else:
-            return utils.as_deferred(
-                on_dir_event_cb, host, Path(filepath.path.decode()), flags)
-
-    async def run_task_instance(self, task: Task) -> None:
-        self._current_task = task.name
-        log.info(_('== running task "{task_name}" for {site_name} =='.format(
-            task_name=task.name, site_name=self.site_name or DEFAULT_SITE_LABEL)))
-        os.chdir(self.site_path)
-        try:
-            await utils.as_deferred(task.start)
-        except Exception as e:
-            on_error = task.ON_ERROR
-            if on_error == 'stop':
-                raise e
-            elif on_error == 'continue':
-                log.warning(_('Task "{task_name}" failed for {site_name}: {reason}')
-                    .format(task_name=task.name, site_name=self.site_name, reason=e))
-            else:
-                raise exceptions.InternalError("we should never reach this point")
-        self._current_task = None
-
-    async def run_task(self, task_name: str) -> None:
-        """Run a single task
-
-        @param task_name(unicode): name of the task to run
-        """
-        task = self.tasks[task_name]
-        await self.run_task_instance(task)
-
-    async def run_tasks(self):
-        """Run all the tasks found"""
-        old_path = os.getcwd()
-        for task_name, task_value in self.tasks.items():
-            await self.run_task(task_name)
-        os.chdir(old_path)
--- a/libervia/server/tasks/task.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,90 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-from twisted.python.procutils import which
-from sat.core.log import getLogger
-from sat.tools.common import async_process
-from sat.core import exceptions
-from sat.core.i18n import _
-from typing import Optional
-
-log = getLogger(__name__)
-
-
-class Task:
-    """Handle tasks of a Libervia site"""
-    # can be "stop" or "continue"
-    ON_ERROR: str = "stop"
-    LOG_OUTPUT: bool = True
-    # list of directories to check for restarting this task
-    # Task.on_dir_event will be called if it exists, otherwise
-    # the task will be run and Task.start will be called
-    WATCH_DIRS: Optional[list] = None
-    # list of task names which must be prepared/started before this one
-    AFTER: Optional[list] = None
-
-    def __init__(self, manager, task_name):
-        self.manager = manager
-        self.name = task_name
-
-    @property
-    def host(self):
-        return self.manager.host
-
-    @property
-    def resource(self):
-        return self.manager.resource
-
-    @property
-    def site_path(self):
-        return self.manager.site_path
-
-    @property
-    def build_path(self):
-        """path where generated files will be build for this site"""
-        return self.manager.build_path
-
-    def config_get(self, key, default=None, value_type=None):
-        return self.host.config_get(self.resource, key=key, default=default,
-                                   value_type=value_type)
-
-    @property
-    def site_name(self):
-        return self.resource.site_name
-
-    def find_command(self, name, *args):
-        """Find full path of a shell command
-
-        @param name(unicode): name of the command to find
-        @param *args(unicode): extra names the command may have
-        @return (unicode): full path of the command
-        @raise exceptions.NotFound: can't find this command
-        """
-        names = (name,) + args
-        for n in names:
-            try:
-                cmd_path = which(n)[0]
-            except IndexError:
-                pass
-            else:
-                return cmd_path
-        raise exceptions.NotFound(_(
-            "Can't find {name} command, did you install it?").format(name=name))
-
-    def runCommand(self, command, *args, **kwargs):
-        kwargs['verbose'] = self.LOG_OUTPUT
-        return async_process.CommandProtocol.run(command, *args, **kwargs)
--- a/libervia/server/utils.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,115 +0,0 @@
-#!/usr/bin/env python3
-
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-from sat.core.i18n import _
-from twisted.internet import reactor
-from twisted.internet import defer
-from sat.core import exceptions
-from sat.core.log import getLogger
-import urllib.request, urllib.parse, urllib.error
-
-log = getLogger(__name__)
-
-
-def quote(value, safe="@"):
-    """shortcut to quote an unicode value for URL"""
-    return urllib.parse.quote(value, safe=safe)
-
-
-class ProgressHandler(object):
-    """class to help the management of progressions"""
-
-    handlers = {}
-
-    def __init__(self, host, progress_id, profile):
-        self.host = host
-        self.progress_id = progress_id
-        self.profile = profile
-
-    @classmethod
-    def _signal(cls, name, progress_id, data, profile):
-        handlers = cls.handlers
-        if profile in handlers and progress_id in handlers[profile]:
-            handler_data = handlers[profile][progress_id]
-            timeout = handler_data["timeout"]
-            if timeout.active():
-                timeout.cancel()
-            cb = handler_data[name]
-            if cb is not None:
-                cb(data)
-            if name == "started":
-                pass
-            elif name == "finished":
-                handler_data["deferred"].callback(data)
-                handler_data["instance"].unregister_handler()
-            elif name == "error":
-                handler_data["deferred"].errback(Exception(data))
-                handler_data["instance"].unregister_handler()
-            else:
-                log.error("unexpected signal: {name}".format(name=name))
-
-    def _timeout(self):
-        log.warning(
-            _(
-                "No progress received, cancelling handler: {progress_id} [{profile}]"
-            ).format(progress_id=self.progress_id, profile=self.profile)
-        )
-
-    def unregister_handler(self):
-        """remove a previously registered handler"""
-        try:
-            del self.handlers[self.profile][self.progress_id]
-        except KeyError:
-            log.warning(
-                _("Trying to remove unknown handler: {progress_id} [{profile}]").format(
-                    progress_id=self.progress_id, profile=self.profile
-                )
-            )
-        else:
-            if not self.handlers[self.profile]:
-                self.handlers[self.profile]
-
-    def register(self, started_cb=None, finished_cb=None, error_cb=None, timeout=30):
-        """register the signals to handle progression
-
-        @param started_cb(callable, None): method to call when progress_started signal is received
-        @param finished_cb(callable, None): method to call when progress_finished signal is received
-        @param error_cb(callable, None): method to call when progress_error signal is received
-        @param timeout(int): progress time out
-            if nothing happen in this progression during this delay,
-            an exception is raised
-        @return (D(dict[unicode,unicode])): a deferred called when progression is finished
-        """
-        handler_data = self.handlers.setdefault(self.profile, {}).setdefault(
-            self.progress_id, {}
-        )
-        if handler_data:
-            raise exceptions.ConflictError(
-                "There is already one handler for this progression"
-            )
-        handler_data["instance"] = self
-        deferred = handler_data["deferred"] = defer.Deferred()
-        handler_data["started"] = started_cb
-        handler_data["finished"] = finished_cb
-        handler_data["error"] = error_cb
-        handler_data["timeout"] = reactor.callLater(timeout, self._timeout)
-        return deferred
-
-
-class SubPage(str):
-    """use to mark subpages when generating a page path"""
--- a/libervia/server/websockets.py	Thu Jun 01 21:42:02 2023 +0200
+++ /dev/null	Thu Jan 01 00:00:00 1970 +0000
@@ -1,224 +0,0 @@
-#!/usr/bin/env python3
-
-# Libervia: a Salut à Toi frontend
-# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
-
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU Affero General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-# GNU Affero General Public License for more details.
-
-# You should have received a copy of the GNU Affero General Public License
-# along with this program.  If not, see <http://www.gnu.org/licenses/>.
-
-
-import json
-from typing import Optional
-
-from autobahn.twisted import websocket
-from autobahn.twisted import resource as resource
-from autobahn.websocket import types
-from sat.core import exceptions
-from sat.core.i18n import _
-from sat.core.log import getLogger
-
-from . import session_iface
-from .constants import Const as C
-
-log = getLogger(__name__)
-
-host = None
-
-
-class LiberviaPageWSProtocol(websocket.WebSocketServerProtocol):
-
-    def __init__(self):
-        super().__init__()
-        self._init_ok: bool = False
-        self.__profile: Optional[str] = None
-        self.__session: Optional[session_iface.WebSession] = None
-
-    @property
-    def init_ok(self):
-        return self._init_ok
-
-    def send(self, data_type: str, data: dict) -> None:
-        """Send data to frontend"""
-        if not self._init_ok and data_type != "error":
-            raise exceptions.InternalError(
-                "send called when not initialized, this should not happend! Please use "
-                "WebSession.send which takes care of sending correctly the data to all "
-                "sessions."
-            )
-
-        data_root = {
-            "type": data_type,
-            "data": data
-        }
-        self.sendMessage(json.dumps(data_root, ensure_ascii=False).encode())
-
-    def close(self) -> None:
-        log.debug(f"closing websocket for profile {self.__profile}")
-
-    def error(self, error_type: str, msg: str) -> None:
-        """Send an error message to frontend and log it locally"""
-        log.warning(
-            f"websocket error {error_type}: {msg}"
-        )
-        self.send("error", {
-            "type": error_type,
-            "msg": msg,
-        })
-
-    def onConnect(self, request):
-        if "libervia-page" not in request.protocols:
-            raise types.ConnectionDeny(
-                types.ConnectionDeny.NOT_IMPLEMENTED, "No supported protocol"
-            )
-        self._init_ok = False
-        cookies = {}
-        for cookie in request.headers.get("cookie", "").split(";"):
-            k, __, v = cookie.partition("=")
-            cookies[k.strip()] = v.strip()
-        session_uid = (
-            cookies.get("TWISTED_SECURE_SESSION")
-            or cookies.get("TWISTED_SESSION")
-            or ""
-        )
-        if not session_uid:
-            raise types.ConnectionDeny(
-                types.ConnectionDeny.FORBIDDEN, "No session set"
-            )
-        try:
-            session = host.site.getSession(session_uid.encode())
-        except KeyError:
-            raise types.ConnectionDeny(
-                types.ConnectionDeny.FORBIDDEN, "Invalid session"
-            )
-
-        session.touch()
-        session_data = session.getComponent(session_iface.IWebSession)
-        if session_data.ws_socket is not None:
-            log.warning(f"Session socket is already set {session_data.ws_socket=} {self=}], force closing it")
-            try:
-                session_data.ws_socket.send(
-                    "force_close", {"reason": "duplicate connection detected"}
-                )
-            except Exception as e:
-                log.warning(f"Can't force close old connection: {e}")
-        session_data.ws_socket = self
-        self.__session = session_data
-        self.__profile = session_data.profile or C.SERVICE_PROFILE
-        log.debug(f"websocket connection connected for profile {self.__profile}")
-        return "libervia-page"
-
-    def on_open(self):
-        log.debug("websocket connection opened")
-
-    def onMessage(self, payload: bytes, isBinary: bool) -> None:
-        if self.__session is None:
-            raise exceptions.InternalError("empty session, this should never happen")
-        try:
-            data_full = json.loads(payload.decode())
-            data_type = data_full["type"]
-            data = data_full["data"]
-        except ValueError as e:
-            self.error(
-                "bad_request",
-                f"Not valid JSON, ignoring data ({e}): {payload!r}"
-            )
-            return
-        except KeyError:
-            self.error(
-                "bad_request",
-                'Invalid request (missing "type" or "data")'
-            )
-            return
-
-        if data_type == "init":
-            if self._init_ok:
-                self.error(
-                    "bad_request",
-                    "double init"
-                )
-                self.sendClose(4400, "Bad Request")
-                return
-
-            try:
-                profile = data["profile"] or C.SERVICE_PROFILE
-                token = data["token"]
-            except KeyError:
-                self.error(
-                    "bad_request",
-                    "Invalid init data (missing profile or token)"
-                )
-                self.sendClose(4400, "Bad Request")
-                return
-            if ((
-                profile != self.__profile
-                or (token != self.__session.ws_token and profile != C.SERVICE_PROFILE)
-            )):
-                log.debug(
-                    f"profile got {profile}, was expecting {self.__profile}, "
-                    f"token got {token}, was expecting {self.__session.ws_token}, "
-                )
-                self.error(
-                    "Unauthorized",
-                    "Invalid profile or token"
-                )
-                self.sendClose(4401, "Unauthorized")
-                return
-            else:
-                log.debug(f"websocket connection initialized for {profile}")
-                self._init_ok = True
-                # we now send all cached data, if any
-                while True:
-                    try:
-                        session_kw = self.__session.ws_buffer.popleft()
-                    except IndexError:
-                        break
-                    else:
-                        self.send(**session_kw)
-
-        if not self._init_ok:
-            self.error(
-                "Unauthorized",
-                "session not authorized"
-            )
-            self.sendClose(4401, "Unauthorized")
-            return
-
-    def on_close(self, wasClean, code, reason):
-        log.debug(f"closing websocket (profile: {self.__profile}, reason: {reason})")
-        if self.__profile is None:
-            log.error("self.__profile should not be None")
-            self.__profile = C.SERVICE_PROFILE
-
-        if self.__session is None:
-            log.warning("closing a socket without attached session")
-        elif self.__session.ws_socket != self:
-            log.error("session socket is not linked to our instance")
-        else:
-            log.debug(f"reseting websocket session for {self.__profile}")
-            self.__session.ws_socket = None
-        sessions = session_iface.WebSession.get_profile_sessions(self.__profile)
-        log.debug(f"websocket connection for profile {self.__profile} closed")
-        self.__profile = None
-
-    @classmethod
-    def get_base_url(cls, secure):
-        return "ws{sec}://localhost:{port}".format(
-            sec="s" if secure else "",
-            port=host.options["port_https" if secure else "port"],
-        )
-
-    @classmethod
-    def get_resource(cls, secure):
-        factory = websocket.WebSocketServerFactory(cls.get_base_url(secure))
-        factory.protocol = cls
-        return resource.WebSocketResource(factory)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/VERSION	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,1 @@
+0.9.0D
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,5 @@
+import os.path
+
+version_file = os.path.join(os.path.dirname(__file__), "VERSION")
+with open(version_file) as f:
+    __version__ = f.read().strip()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/common/constants.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+
+
+# Libervia: a SAT frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.frontends.quick_frontend import constants
+import os.path
+
+
+class Const(constants.Const):
+
+    # XXX: we don't want to use the APP_VERSION inherited from libervia.backend.core.constants version
+    #      as we use this version to check that there is not a mismatch with backend
+    APP_VERSION = "0.9.0D"  # Please add 'D' at the end for dev versions
+    LIBERVIA_MAIN_PAGE = "libervia.html"
+    LIBERVIA_PAGE_START = "/login"
+
+    # REGISTRATION
+    # XXX: for now libervia forces the creation to lower case
+    # XXX: Regex patterns must be compatible with both Python and JS
+    REG_LOGIN_RE = r"^[a-z0-9_-]+$"
+    REG_EMAIL_RE = r"^.+@.+\..+"
+    PASSWORD_MIN_LENGTH = 6
+
+    # HTTP REQUEST RESULT VALUES
+    PROFILE_AUTH_ERROR = "PROFILE AUTH ERROR"
+    XMPP_AUTH_ERROR = "XMPP AUTH ERROR"
+    ALREADY_WAITING = "ALREADY WAITING"
+    SESSION_ACTIVE = "SESSION ACTIVE"
+    NOT_CONNECTED = "NOT CONNECTED"
+    PROFILE_LOGGED = "LOGGED"
+    PROFILE_LOGGED_EXT_JID = "LOGGED (REGISTERED WITH EXTERNAL JID)"
+    ALREADY_EXISTS = "ALREADY EXISTS"
+    INVALID_CERTIFICATE = "INVALID CERTIFICATE"
+    REGISTRATION_SUCCEED = "REGISTRATION"
+    INTERNAL_ERROR = "INTERNAL ERROR"
+    INVALID_INPUT = "INVALID INPUT"
+    BAD_REQUEST = "BAD REQUEST"
+    NO_REPLY = "NO REPLY"
+    NOT_ALLOWED = "NOT ALLOWED"
+    UPLOAD_OK = "UPLOAD OK"
+    UPLOAD_KO = "UPLOAD KO"
+
+    # directories
+    MEDIA_DIR = "media/"
+    CACHE_DIR = "cache"
+
+    # avatars
+    DEFAULT_AVATAR_FILE = "default_avatar.png"
+    DEFAULT_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", DEFAULT_AVATAR_FILE)
+    EMPTY_AVATAR_FILE = "empty_avatar"
+    EMPTY_AVATAR_URL = os.path.join(MEDIA_DIR, "misc", EMPTY_AVATAR_FILE)
+
+    # blog
+    MAM_FILTER_CATEGORY = "http://salut-a-toi.org/protocols/mam_filter_category"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_bridge/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,75 @@
+#!/usr/bin/env python3
+
+import json
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.web.server.constants import Const as C
+
+
+log = getLogger(__name__)
+"""access to restricted bridge"""
+
+name = "bridge"
+on_data_post = "continue"
+
+# bridge method allowed when no profile is connected
+NO_SESSION_ALLOWED = ("contacts_get", "identities_base_get", "identities_get")
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["method_name"], min_args=1)
+
+
+async def render(self, request):
+    if request.method != b'POST':
+        log.warning(f"Bad method used with _bridge endpoint: {request.method.decode()}")
+        return self.page_error(request, C.HTTP_BAD_REQUEST)
+    data = self.get_r_data(request)
+    profile = self.get_profile(request)
+    self.check_csrf(request)
+    method_name = data["method_name"]
+    if profile is None:
+        if method_name in NO_SESSION_ALLOWED:
+            # this method is allowed, we use the service profile
+            profile = C.SERVICE_PROFILE
+        else:
+            log.warning("_bridge endpoint accessed without authorisation")
+            return self.page_error(request, C.HTTP_UNAUTHORIZED)
+    method_data = json.load(request.content)
+    try:
+        bridge_method = getattr(self.host.restricted_bridge, method_name)
+    except AttributeError:
+        log.warning(_(
+            "{profile!r} is trying to access a bridge method not implemented in "
+            "RestrictedBridge: {method_name}").format(
+                profile=profile, method_name=method_name))
+        return self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    try:
+        args, kwargs = method_data['args'], method_data['kwargs']
+    except KeyError:
+        log.warning(_(
+            "{profile!r} has sent a badly formatted method call: {method_data}"
+        ).format(profile=profile, method_data=method_data))
+        return self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    if "profile" in kwargs or "profile_key" in kwargs:
+        log.warning(_(
+            '"profile" key should not be in method kwargs, hack attempt? '
+            "profile={profile}, method_data={method_data}"
+        ).format(profile=profile, method_data=method_data))
+        return self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    try:
+        ret = await bridge_method(*args, **kwargs, profile=profile)
+    except BridgeException as e:
+        request.setResponseCode(C.HTTP_PROXY_ERROR)
+        ret = {
+            "fullname": e.fullname,
+            "message": e.message,
+            "condition": e.condition,
+            "module": e.module,
+            "classname": e.classname,
+        }
+    return json.dumps(ret)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,5 @@
+import bridge
+
+
+# we create a bridge instance to receive signals
+bridge.Bridge()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/alt_media_player.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,327 @@
+#!/usr/bin/env python3
+
+"""This module implement an alternative media player
+
+If browser can't play natively some libre video/audio formats, ogv.js will be used,
+otherwise the native player will be used.
+
+This player uses its own controls, this allow better tuning/event handling notably with
+slideshow.
+"""
+
+from browser import document, timer, html
+
+
+NO_PAGINATION = "NO_PAGINATION"
+NO_SCROLLBAR = "NO_SCROLLBAR"
+
+
+class MediaPlayer:
+    TIMER_MODES = ("timer", "remaining")
+    # will be set to False if browser can't play natively webm or ogv
+    native = True
+    # will be set to True when template and modules will be imported
+    imports_done = False
+
+    def __init__(
+            self,
+            sources,
+            to_rpl_vid_elt=None,
+            poster=None,
+            reduce_click_area=False
+    ):
+        """
+        @param sources: list of paths to media
+            only the first one is used at the moment
+        @param to_rpl_vid_elt: video element to replace
+            if None, nothing is replaced and element must be inserted manually
+        @param reduce_click_area: when True, only center of the element will react to
+            click. Useful when used in slideshow, as click on border is used to
+            show/hide slide controls
+        """
+        self.do_imports()
+
+        self.reduce_click_area = reduce_click_area
+
+        self.media_player_elt = media_player_elt = media_player_tpl.get_elt()
+        self.player = player = self._create_player(sources, poster)
+        if to_rpl_vid_elt is not None:
+            to_rpl_vid_elt.parentNode.replaceChild(media_player_elt, to_rpl_vid_elt)
+        overlay_play_elt = self.media_player_elt.select_one(".media_overlay_play")
+        overlay_play_elt.bind("click", self.on_play_click)
+        self.progress_elt = media_player_elt.select_one("progress")
+        self.progress_elt.bind("click", self.on_progress_click)
+        self.timer_elt = media_player_elt.select_one(".timer")
+        self.timer_mode = "timer"
+
+        self.controls_elt = media_player_elt.select_one(".media_controls")
+        # we devnull 2 following events to avoid accidental side effect
+        # this is notably useful in slideshow to avoid changing the slide when
+        # the user misses slightly a button
+        self.controls_elt.bind("mousedown", self._devnull)
+        self.controls_elt.bind("click", self._devnull)
+
+        player_wrapper_elt = media_player_elt.select_one(".media_elt")
+        player.preload = "none"
+        player.src = sources[0]
+        player_wrapper_elt <= player
+        self.hide_controls_timer = None
+
+        # we capture mousedown to avoid side effect on slideshow
+        player_wrapper_elt.addEventListener("mousedown", self._devnull)
+        player_wrapper_elt.addEventListener("click", self.on_player_click)
+
+        # buttons
+        for handler in ("play", "change_timer_mode", "change_volume", "fullscreen"):
+            for elt in media_player_elt.select(f".click_to_{handler}"):
+                elt.bind("click", getattr(self, f"on_{handler}_click"))
+        # events
+        # FIXME: progress is not implemented in OGV.js, update when available
+        for event in ("play", "pause", "timeupdate", "ended", "volumechange"):
+            player.bind(event, getattr(self, f"on_{event}"))
+
+    @property
+    def elt(self):
+        return self.media_player_elt
+
+    def _create_player(self, sources, poster):
+        """Create player element, using native one when possible"""
+        player = None
+        if not self.native:
+            source = sources[0]
+            ext = self.get_source_ext(source)
+            if ext is None:
+                print(
+                    f"no extension found for {source}, using native player"
+                )
+            elif ext in self.cant_play_ext_list:
+                print(f"OGV player user for {source}")
+                player = self.ogv.OGVPlayer.new()
+                # OGCPlayer has non standard "poster" property
+                player.poster = poster
+        if player is None:
+            player = html.VIDEO(poster=poster)
+        return player
+
+    def reset(self):
+        """Put back media player in intial state
+
+        media will be stopped, time will be set to beginning, overlay will be put back
+        """
+        print("resetting media player")
+        self.player.pause()
+        self.player.currentTime = 0
+        self.media_player_elt.classList.remove("in_use")
+
+    def _devnull(self, evt):
+        # stop an event
+        evt.preventDefault()
+        evt.stopPropagation()
+
+    def on_player_click(self, evt):
+        if self.reduce_click_area:
+            bounding_rect = self.media_player_elt.getBoundingClientRect()
+            margin_x = margin_y = 200
+            if ((evt.clientX - bounding_rect.left < margin_x
+                 or bounding_rect.right - evt.clientX < margin_x
+                 or evt.clientY - bounding_rect.top < margin_y
+                 or bounding_rect.bottom - evt.clientY < margin_y
+               )):
+                # click is not in the center, we don't handle it and let the event
+                # propagate
+                return
+        self.on_play_click(evt)
+
+    def on_play_click(self, evt):
+        evt.preventDefault()
+        evt.stopPropagation()
+        self.media_player_elt.classList.add("in_use")
+        if self.player.paused:
+            print("playing")
+            self.player.play()
+        else:
+            self.player.pause()
+            print("paused")
+
+    def on_change_timer_mode_click(self, evt):
+        evt.preventDefault()
+        evt.stopPropagation()
+        self.timer_mode = self.TIMER_MODES[
+            (self.TIMER_MODES.index(self.timer_mode) + 1) % len(self.TIMER_MODES)
+        ]
+
+    def on_change_volume_click(self, evt):
+        evt.stopPropagation()
+        self.player.muted = not self.player.muted
+
+    def on_fullscreen_click(self, evt):
+        evt.stopPropagation()
+        try:
+            fullscreen_elt = document.fullscreenElement
+            request_fullscreen = self.media_player_elt.requestFullscreen
+        except AttributeError:
+            print("fullscreen is not available on this browser")
+        else:
+            if fullscreen_elt == None:
+                print("requesting fullscreen")
+                request_fullscreen()
+            else:
+                print(f"leaving fullscreen: {fullscreen_elt}")
+                try:
+                    document.exitFullscreen()
+                except AttributeError:
+                    print("exitFullscreen not available on this browser")
+
+    def on_progress_click(self, evt):
+        evt.stopPropagation()
+        position = evt.offsetX / evt.target.width
+        new_time = self.player.duration * position
+        self.player.currentTime = new_time
+
+    def on_play(self, evt):
+        self.media_player_elt.classList.add("playing")
+        self.show_controls()
+        self.media_player_elt.bind("mousemove", self.on_mouse_move)
+
+    def on_pause(self, evt):
+        self.media_player_elt.classList.remove("playing")
+        self.show_controls()
+        self.media_player_elt.unbind("mousemove")
+
+    def on_timeupdate(self, evt):
+        self.update_progress()
+
+    def on_ended(self, evt):
+        self.update_progress()
+
+    def on_volumechange(self, evt):
+        evt.stopPropagation()
+        if self.player.muted:
+            self.media_player_elt.classList.add("muted")
+        else:
+            self.media_player_elt.classList.remove("muted")
+
+    def on_mouse_move(self, evt):
+        self.show_controls()
+
+    def update_progress(self):
+        duration = self.player.duration
+        current_time = duration if self.player.ended else self.player.currentTime
+        self.progress_elt.max = duration
+        self.progress_elt.value = current_time
+        self.progress_elt.text = f"{current_time/duration*100:.02f}"
+        current_time, duration = int(current_time), int(duration)
+        if self.timer_mode == "timer":
+            cur_min, cur_sec = divmod(current_time, 60)
+            tot_min, tot_sec = divmod(duration, 60)
+            self.timer_elt.text = f"{cur_min}:{cur_sec:02d}/{tot_min}:{tot_sec:02d}"
+        elif self.timer_mode == "remaining":
+            rem_min, rem_sec = divmod(duration - current_time, 60)
+            self.timer_elt.text = f"{rem_min}:{rem_sec:02d}"
+        else:
+            print(f"ERROR: unknown timer mode: {self.timer_mode}")
+
+    def hide_controls(self):
+        self.controls_elt.classList.add("hidden")
+        self.media_player_elt.style.cursor = "none"
+        if self.hide_controls_timer is not None:
+            timer.clear_timeout(self.hide_controls_timer)
+            self.hide_controls_timer = None
+
+    def show_controls(self):
+        self.controls_elt.classList.remove("hidden")
+        self.media_player_elt.style.cursor = ""
+        if self.hide_controls_timer is not None:
+            timer.clear_timeout(self.hide_controls_timer)
+        if self.player.paused:
+            self.hide_controls_timer = None
+        else:
+            self.hide_controls_timer = timer.set_timeout(self.hide_controls, 3000)
+
+    @classmethod
+    def do_imports(cls):
+        # we do imports (notably for ogv.js) only if they are necessary
+        if cls.imports_done:
+            return
+        if not cls.native:
+            from js_modules import ogv
+            cls.ogv = ogv
+            if not ogv.OGVCompat.supported('OGVPlayer'):
+                print("Can't use OGVPlayer with this browser")
+                raise NotImplementedError
+        import template
+        global media_player_tpl
+        media_player_tpl = template.Template("components/media_player.html")
+        cls.imports_done = True
+
+    @staticmethod
+    def get_source_ext(source):
+        try:
+            ext = f".{source.rsplit('.', 1)[1].strip()}"
+        except IndexError:
+            return None
+        return ext or None
+
+    @classmethod
+    def install(cls, cant_play):
+        cls.native = False
+        ext_list = set()
+        for data in cant_play.values():
+            ext_list.update(data['ext'])
+        cls.cant_play_ext_list = ext_list
+        for to_rpl_vid_elt in document.body.select('video'):
+            sources = []
+            src = (to_rpl_vid_elt.src or '').strip()
+            if src:
+                sources.append(src)
+
+            for source_elt in to_rpl_vid_elt.select('source'):
+                src = (source_elt.src or '').strip()
+                if src:
+                    sources.append(src)
+
+            # FIXME: we only use first found source
+            try:
+                source = sources[0]
+            except IndexError:
+                print(f"Can't find any source for following elt:\n{to_rpl_vid_elt.html}")
+                continue
+
+            ext = cls.get_source_ext(source)
+
+            ext = f".{source.rsplit('.', 1)[1]}"
+            if ext is None:
+                print(
+                    "No extension found for source of following elt:\n"
+                    f"{to_rpl_vid_elt.html}"
+                )
+                continue
+            if ext in ext_list:
+                print(f"alternative player will be used for {source!r}")
+                cls(sources, to_rpl_vid_elt)
+
+
+def install_if_needed():
+    CONTENT_TYPES = {
+        "ogg_theora": {"type": 'video/ogg; codecs="theora"', "ext": [".ogv", ".ogg"]},
+        "webm_vp8": {"type": 'video/webm; codecs="vp8, vorbis"', "ext": [".webm"]},
+        "webm_vp9": {"type": 'video/webm; codecs="vp9"', "ext": [".webm"]},
+        # FIXME: handle audio
+        # "ogg_vorbis": {"type": 'audio/ogg; codecs="vorbis"', "ext": ".ogg"},
+    }
+    test_media_elt = html.VIDEO()
+    cant_play = {k:d for k,d in CONTENT_TYPES.items()
+                 if test_media_elt.canPlayType(d['type']) != "probably"}
+
+    if cant_play:
+        cant_play_list = '\n'.join(f"- {k} ({d['type']})" for k, d in cant_play.items())
+        print(
+            "This browser is incompatible with following content types, using "
+            f"alternative:\n{cant_play_list}"
+        )
+        try:
+            MediaPlayer.install(cant_play)
+        except NotImplementedError:
+            pass
+    else:
+        print("This browser can play natively all requested open video/audio formats")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/bridge.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,348 @@
+from browser import window, aio, timer, console as log
+import time
+import random
+import json
+import dialog
+import javascript
+
+
+log.warning = log.warn
+tab_id = random.randint(0, 2**64)
+log.info(f"TAB ID is {tab_id}")
+
+
+class BridgeException(Exception):
+    """An exception which has been raised from the backend and arrived to the frontend."""
+
+    def __init__(self, name, message="", condition=""):
+        """
+
+        @param name (str): full exception class name (with module)
+        @param message (str): error message
+        @param condition (str) : error condition
+        """
+        Exception.__init__(self)
+        self.fullname = str(name)
+        self.message = str(message)
+        self.condition = str(condition) if condition else ""
+        self.module, __, self.classname = str(self.fullname).rpartition(".")
+
+    def __str__(self):
+        return f"{self.classname}: {self.message or ''}"
+
+    def __eq__(self, other):
+        return self.classname == other
+
+
+class WebSocket:
+
+    def __init__(self, broadcast_channel):
+        self.broadcast_channel = broadcast_channel
+        self.token = window.ws_token
+        self.create_socket()
+        self.retrying = False
+        self.network_error = False
+
+    @property
+    def profile(self):
+        return self.broadcast_channel.profile
+
+    def retry_connect(self) -> None:
+        if self.retrying:
+            return
+        self.retrying = True
+        try:
+            notif = dialog.RetryNotification(self.create_socket)
+            notif.show(
+                "Can't connect to server",
+                delay=random.randint(0, 30)
+            )
+        except Exception as e:
+            # for security reasons, browser don't give the reason of the error with
+            # WebSockets, thus we try to detect network error here, as if we can't show
+            # the retry dialog, that probably means that it's not reachable
+            try:
+                name = e.name
+            except AttributeError:
+                name = None
+            if name == "NetworkError":
+                self.network_error = True
+                log.warning("network error detected, server may be down")
+                log.error(f"Can't show retry dialog: {e}")
+                log.info("retrying in 30s")
+                timer.set_timeout(self.create_socket, 30000)
+            else:
+                raise e
+        else:
+            # if we can show the retry dialog, the network is fine
+            self.network_error = False
+
+    def create_socket(self) -> None:
+        log.debug("creating socket")
+        self.retrying = False
+        self.socket = window.WebSocket.new(window.ws_url, "libervia-page")
+        self.socket_start = time.time()
+        self.socket.bind("open", self.on_open)
+        self.socket.bind("error", self.on_error)
+        self.socket.bind("close", self.on_close)
+        self.socket.bind("message", self.on_message)
+
+    def send(self, data_type: str, data: dict) -> None:
+        self.socket.send(json.dumps({
+            "type": data_type,
+            "data": data
+        }))
+
+    def close(self) -> None:
+        log.debug("closing socket")
+        self.broadcast_channel.ws = None
+        self.socket.close()
+
+    def on_open(self, evt) -> None:
+        log.info("websocket connection opened")
+        self.send("init", {"profile": self.profile, "token": self.token})
+
+    def on_error(self, evt) -> None:
+        if not self.network_error and time.time() - self.socket_start < 5:
+            # disconnection is happening fast, we try to reload
+            log.warning("Reloading due to suspected session error")
+            window.location.reload()
+        else:
+            self.retry_connect()
+
+    def on_close(self, evt) -> None:
+        log.warning(f"websocket is closed {evt.code=} {evt.reason=}")
+        if self.broadcast_channel.ws is None:
+            # this is a close requested locally
+            return
+        elif evt.code == 4401:
+            log.info(
+                "no authorized, the session is probably not valid anymore, reloading"
+            )
+            window.location.reload()
+        else:
+            # close event may be due to normal tab closing, thus we try to reconnect only
+            # after a delay
+            timer.set_timeout(self.retry_connect, 5000)
+
+    def on_message(self, message_evt):
+        msg_data = json.loads(message_evt.data)
+        msg_type = msg_data.get("type")
+        if msg_type == "bridge":
+            log.debug(
+                f"==> bridge message: {msg_data=}"
+            )
+            self.broadcast_channel.post(
+                msg_type,
+                msg_data["data"]
+            )
+        elif msg_type == "force_close":
+            log.warning(f"force closing connection: {msg_data.get('reason')}")
+            self.close()
+        else:
+            dialog.notification.show(
+                f"Unexpected message type {msg_type}"
+                "error"
+            )
+
+
+class BroadcastChannel:
+    handlers = {}
+
+    def __init__(self):
+        log.debug(f"BroadcastChannel init with profile {self.profile!r}")
+        self.start = time.time()
+        self.bc = window.BroadcastChannel.new("libervia")
+        self.bc.bind("message", self.on_message)
+        # there is no way to check if there is already a connection in BroadcastChannel
+        # API, thus we wait a bit to see if somebody is answering. If not, we are probably
+        # the first tab.
+        self.check_connection_timer = timer.set_timeout(self.establish_connection, 20)
+        self.ws = None
+        # set of all known tab ids
+        self.tabs_ids = {tab_id}
+        self.post("salut_a_vous", {
+            "id": tab_id,
+            "profile": self.profile
+        })
+        window.bind("unload", self.on_unload)
+
+    @property
+    def profile(self):
+        return window.profile or ""
+
+    @property
+    def connecting_tab(self) -> bool:
+        """True is this tab is the one establishing the websocket connection"""
+        return self.ws is not None
+
+    @connecting_tab.setter
+    def connecting_tab(self, connecting: bool) -> None:
+        if connecting:
+            if self.ws is None:
+                self.ws = WebSocket(self)
+                self.post("connection", {
+                    "tab_id": tab_id
+                })
+        elif self.ws is not None:
+            self.ws.close()
+
+    def establish_connection(self) -> None:
+        """Called when there is no existing connection"""
+        timer.clear_timeout(self.check_connection_timer)
+        log.debug(f"Establishing connection {tab_id=}")
+        self.connecting_tab = True
+
+    def handle_bridge_signal(self, data: dict) -> None:
+        """Forward bridge signals to registered handlers"""
+        signal = data["signal"]
+        handlers = self.handlers.get(signal, [])
+        for handler in handlers:
+            handler(*data["args"])
+
+    def on_message(self, evt) -> None:
+        data = json.loads(evt.data)
+        if data["type"] == "bridge":
+            self.handle_bridge_signal(data)
+        elif data["type"] == "salut_a_toi":
+            # this is a response from existing tabs
+            other_tab_id = data["id"]
+            if other_tab_id == tab_id:
+                # in the unlikely case that this happens, we simply reload this tab to get
+                # a new ID
+                log.warning("duplicate tab id, we reload the page: {tab_id=}")
+                window.location.reload()
+                return
+            self.tabs_ids.add(other_tab_id)
+            if data["connecting_tab"] and self.check_connection_timer is not None:
+                # this tab has the websocket connection to server
+                log.info(f"there is already a connection to server at tab {other_tab_id}")
+                timer.clear_timeout(self.check_connection_timer)
+                self.check_connection_timer = None
+        elif data["type"] == "salut_a_vous":
+            # a new tab has just been created
+            if data["profile"] != self.profile:
+                log.info(
+                    f"we are now connected with the profile {data['profile']}, "
+                    "reloading the page"
+                )
+                window.location.reload()
+            else:
+                self.tabs_ids.add(data["id"])
+                self.post("salut_a_toi", {
+                    "id": tab_id,
+                    "connecting_tab": self.connecting_tab
+                })
+        elif data["type"] == "connection":
+            log.info(f"tab {data['id']} is the new connecting tab")
+        elif data["type"] == "salut_a_rantanplan":
+            # a tab is being closed
+            other_tab_id = data["id"]
+            # it is unlikely that there is a collision, but just in case we check it
+            if other_tab_id != tab_id:
+                self.tabs_ids.discard(other_tab_id)
+            if data["connecting_tab"]:
+                log.info(f"connecting tab with id {other_tab_id} has been closed")
+                if max(self.tabs_ids) == tab_id:
+                    log.info("this is the new connecting tab, establish_connection")
+                    self.connecting_tab = True
+            else:
+                log.info(f"tab with id {other_tab_id} has been closed")
+        else:
+            log.warning(f"unknown message type: {data}")
+
+    def post(self, data_type, data: dict):
+        data["type"] = data_type
+        data["id"] = tab_id
+        self.bc.postMessage(json.dumps(data))
+        if data_type == "bridge":
+            self.handle_bridge_signal(data)
+
+    def on_unload(self, evt) -> None:
+        """Send a message to indicate that the tab is being closed"""
+        self.post("salut_a_rantanplan", {
+            "id": tab_id,
+            "connecting_tab": self.connecting_tab
+        })
+
+
+class Bridge:
+    bc: BroadcastChannel | None = None
+
+    def __init__(self) -> None:
+        if Bridge.bc is None:
+            Bridge.bc = BroadcastChannel()
+
+    def __getattr__(self, attr):
+        return lambda *args, **kwargs: self.call(attr, *args, **kwargs)
+
+    def on_load(self, xhr, ev, callback, errback):
+        if xhr.status == 200:
+            ret = javascript.JSON.parse(xhr.response)
+            if callback is not None:
+                if ret is None:
+                    callback()
+                else:
+                    callback(ret)
+        elif xhr.status == 502:
+            # PROXY_ERROR is used for bridge error
+            ret = javascript.JSON.parse(xhr.response)
+            if errback is not None:
+                errback(ret)
+        else:
+            log.error(
+                f"bridge call failed: code: {xhr.response}, text: {xhr.statusText}"
+            )
+            if errback is not None:
+                errback({"fullname": "BridgeInternalError", "message": xhr.statusText})
+
+    def call(self, method_name, *args, callback, errback, **kwargs):
+        xhr = window.XMLHttpRequest.new()
+        xhr.bind('load', lambda ev: self.on_load(xhr, ev, callback, errback))
+        xhr.bind('error', lambda ev: errback(
+            {"fullname": "ConnectionError", "message": xhr.statusText}))
+        xhr.open("POST", f"/_bridge/{method_name}", True)
+        data = javascript.JSON.stringify({
+            "args": args,
+            "kwargs": kwargs,
+        })
+        xhr.setRequestHeader('X-Csrf-Token', window.csrf_token)
+        xhr.send(data)
+
+    def register_signal(self, signal: str, handler, iface=None) -> None:
+        BroadcastChannel.handlers.setdefault(signal, []).append(handler)
+        log.debug(f"signal {signal} has been registered")
+
+
+class AsyncBridge:
+
+    def __getattr__(self, attr):
+        return lambda *args, **kwargs: self.call(attr, *args, **kwargs)
+
+    async def call(self, method_name, *args, **kwargs):
+        print(f"calling {method_name}")
+        data = javascript.JSON.stringify({
+            "args": args,
+            "kwargs": kwargs,
+        })
+        url = f"/_bridge/{method_name}"
+        r = await aio.post(
+            url,
+            headers={
+                'X-Csrf-Token': window.csrf_token,
+            },
+            data=data,
+        )
+
+        if r.status == 200:
+            return javascript.JSON.parse(r.data)
+        elif r.status == 502:
+            ret = javascript.JSON.parse(r.data)
+            raise BridgeException(ret['fullname'], ret['message'], ret['condition'])
+        else:
+            print(f"bridge called failed: code: {r.status}, text: {r.statusText}")
+            raise BridgeException("InternalError", r.statusText)
+
+    def register_signal(self, signal: str, handler, iface=None) -> None:
+        BroadcastChannel.handlers.setdefault(signal, []).append(handler)
+        log.debug(f"signal {signal} has been registered")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/browser_meta.json	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,25 @@
+{
+    "js": {
+        "package": {
+            "dependencies": {
+                "nunjucks": "^3.2.3",
+                "swiper": "^6.8.4",
+                "moment": "^2.29.1",
+                "ogv": "^1.8.4"
+            }
+        },
+        "brython_map": {
+            "nunjucks": "nunjucks/browser/nunjucks.min.js",
+            "swiper": {
+                "path": "swiper/swiper-bundle.min.js",
+                "export": ["Swiper"]
+            },
+            "moment": "moment/min/moment.min.js",
+            "ogv": {
+                "path": "ogv/dist/ogv.js",
+                "export": ["OGVCompat", "OGVLoader", "OGVMediaError", "OGVMediaType", "OGVTimeRanges", "OGVPlayer", "OGVVersion"],
+                "extra_init": "OGVLoader.base='/{build_dir}/node_modules/ogv/dist'"
+            }
+        }
+    }
+}
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/cache.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,156 @@
+from browser import window
+from browser.local_storage import storage
+from javascript import JSON
+from dialog import notification
+from bridge import Bridge
+
+session_uuid = window.session_uuid
+bridge = Bridge()
+
+# XXX: we don't use browser.object_storage because it is affected by
+#   https://github.com/brython-dev/brython/issues/1467 and mixing local_storage.storage
+#   and object_storage was resulting in weird behaviour (keys found in one not in the
+#   other)
+
+
+class Cache:
+
+    def __init__(self):
+        try:
+            cache = storage['libervia_cache']
+        except KeyError:
+            self.request_data_from_backend()
+        else:
+            cache = JSON.parse(cache)
+            if cache['metadata']['session_uuid'] != session_uuid:
+                print("data in cache are not valid for this session, resetting")
+                del storage['libervia_cache']
+                self.request_data_from_backend()
+            else:
+                self._cache = cache
+                print("storage cache is used")
+
+    @property
+    def roster(self):
+        return self._cache['roster']
+
+    @property
+    def identities(self):
+        return self._cache['identities']
+
+    def update(self):
+        # FIXME: we use window.JSON as a workaround to
+        #   https://github.com/brython-dev/brython/issues/1467
+        print(f"updating: {self._cache}")
+        storage['libervia_cache'] = window.JSON.stringify(self._cache)
+        print("cache stored")
+
+    def _store_if_complete(self):
+        self._completed_count -= 1
+        if self._completed_count == 0:
+            del self._completed_count
+            self.update()
+
+    def get_contacts_cb(self, contacts):
+        print("roster received")
+        roster = self._cache['roster']
+        for contact_jid, attributes, groups in contacts:
+            roster[contact_jid] = {
+                'attributes': attributes,
+                'groups': groups,
+            }
+        self._store_if_complete()
+
+    def identities_base_get_cb(self, identities_raw):
+        print("base identities received")
+        identities = JSON.parse(identities_raw)
+        self._cache['identities'].update(identities)
+        self._store_if_complete()
+
+    def request_failed(self, exc, message):
+        notification.show(message.format(exc=exc), "error")
+        self._store_if_complete()
+
+    def request_data_from_backend(self):
+        self._cache = {
+            'metadata': {
+                "session_uuid": session_uuid,
+            },
+            'roster': {},
+            'identities': {},
+        }
+        self._completed_count = 2
+        print("requesting roster to backend")
+        bridge.contacts_get(
+            callback=self.get_contacts_cb,
+            errback=lambda e: self.request_failed(e, "Can't get contacts: {exc}")
+        )
+        print("requesting base identities to backend")
+        bridge.identities_base_get(
+            callback=self.identities_base_get_cb,
+            errback=lambda e: self.request_failed(e, "Can't get base identities: {exc}")
+        )
+
+    def _fill_identities_cb(self, new_identities_raw, callback):
+        new_identities = JSON.parse(new_identities_raw)
+        print(f"new identities: {new_identities.keys()}")
+        self._cache['identities'].update(new_identities)
+        self.update()
+        if callback:
+            callback()
+
+    def fill_identities(self, entities, callback=None):
+        """Check that identities for entities exist, request them otherwise"""
+        to_get = {e for e in entities if e not in self._cache['identities']}
+        if to_get:
+            bridge.identities_get(
+                list(to_get),
+                ['avatar', 'nicknames'],
+                callback=lambda identities: self._fill_identities_cb(
+                    identities, callback),
+                errback=lambda failure_: notification.show(
+                    f"Can't get identities: {failure_}",
+                    "error"
+                )
+            )
+        else:
+            # we already have all identities
+            print("no missing identity")
+            if callback:
+                callback()
+
+    def match_identity(self, entity_jid, text, identity=None):
+        """Returns True if a text match an entity identity
+
+        identity will be matching if its jid or any of its name contain text
+        @param entity_jid: jid of the entity to check
+        @param text: text to use for filtering. Must be in lowercase and stripped
+        @param identity: identity data
+            if None, it will be retrieved if jid is not matching
+        @return: True if entity is matching
+        """
+        if text in entity_jid:
+            return True
+        if identity is None:
+            try:
+                identity = self.identities[entity_jid]
+            except KeyError:
+                print(f"missing identity: {entity_jid}")
+                return False
+        return any(text in n.lower() for n in identity['nicknames'])
+
+    def matching_identities(self, text):
+        """Return identities corresponding to a text
+
+        """
+        text = text.lower().strip()
+        for entity_jid, identity in self._cache['identities'].items():
+            if ((text in entity_jid
+                 or any(text in n.lower() for n in identity['nicknames'])
+                 )):
+                yield entity_jid
+
+
+cache = Cache()
+roster = cache.roster
+identities = cache.identities
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/dialog.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,131 @@
+"""manage common dialogs"""
+
+from browser import document, window, timer, console as log
+from template import Template
+
+log.warning = log.warn
+
+
+class Confirm:
+
+    def __init__(self, message, ok_label="", cancel_label="", ok_color="success"):
+        self._tpl = Template("dialogs/confirm.html")
+        self.message = message
+        self.ok_label = ok_label
+        assert ok_color in ("success", "danger")
+        self.ok_color = ok_color
+        self.cancel_label = cancel_label
+
+    def cancel_cb(self, evt, notif_elt):
+        notif_elt.remove()
+
+    def show(self, ok_cb, cancel_cb=None):
+        if cancel_cb is None:
+            cancel_cb = self.cancel_cb
+        notif_elt = self._tpl.get_elt({
+            "message": self.message,
+            "ok_label": self.ok_label,
+            "ok_color": self.ok_color,
+            "cancel_label": self.cancel_label,
+        })
+
+        document['notifs_area'] <= notif_elt
+        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
+        for cancel_elt in notif_elt.select(".click_to_cancel"):
+            cancel_elt.bind("click", lambda evt: cancel_cb(evt, notif_elt))
+        for cancel_elt in notif_elt.select(".click_to_ok"):
+            cancel_elt.bind("click", lambda evt: ok_cb(evt, notif_elt))
+
+    def _ashow_cb(self, evt, notif_elt, resolve_cb, confirmed):
+        evt.stopPropagation()
+        notif_elt.remove()
+        resolve_cb(confirmed)
+
+    async def ashow(self):
+        return window.Promise.new(
+            lambda resolve_cb, reject_cb:
+            self.show(
+                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, True),
+                lambda evt, notif_elt: self._ashow_cb(evt, notif_elt, resolve_cb, False)
+            )
+        )
+
+
+class Notification:
+
+    def __init__(self):
+        self._tpl = Template("dialogs/notification.html")
+
+    def close(self, notif_elt):
+        notif_elt.classList.remove('state_appended')
+        notif_elt.bind("transitionend", lambda __: notif_elt.remove())
+
+    def show(
+        self,
+        message: str,
+        level: str = "info",
+        delay: int = 5
+    ) -> None:
+        # we log in console error messages, may be useful
+        if level in ("warning", "error"):
+            print(f"[{level}] {message}")
+        notif_elt = self._tpl.get_elt({
+            "message": message,
+            "level": level,
+        })
+        document["notifs_area"] <= notif_elt
+        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
+        timer.set_timeout(lambda: self.close(notif_elt), delay * 1000)
+        for elt in notif_elt.select('.click_to_close'):
+            elt.bind('click', lambda __: self.close(notif_elt))
+
+
+class RetryNotification:
+    def __init__(self, retry_cb):
+        self._tpl = Template("dialogs/retry-notification.html")
+        self.retry_cb = retry_cb
+        self.counter = 0
+        self.timer = None
+
+    def retry(self, notif_elt):
+        if self.timer is not None:
+            timer.clear_interval(self.timer)
+            self.timer = None
+        notif_elt.classList.remove('state_appended')
+        notif_elt.bind("transitionend", lambda __: notif_elt.remove())
+        self.retry_cb()
+
+    def update_counter(self, notif_elt):
+        counter = notif_elt.select_one(".retry_counter")
+        counter.text = str(self.counter)
+        self.counter -= 1
+        if self.counter < 0:
+            self.retry(notif_elt)
+
+    def show(
+        self,
+        message: str,
+        level: str = "warning",
+        delay: int = 5
+    ) -> None:
+        # we log in console error messages, may be useful
+        if level == "error":
+            log.error(message)
+        elif level == "warning":
+            log.warning(message)
+        self.counter = delay
+        notif_elt = self._tpl.get_elt({
+            "message": message,
+            "level": level,
+        })
+        self.update_counter(notif_elt)
+        document["notifs_area"] <= notif_elt
+        timer.set_timeout(lambda: notif_elt.classList.add('state_appended'), 0)
+        self.timer = timer.set_interval(self.update_counter, 1000, notif_elt)
+        for elt in notif_elt.select('.click_to_retry'):
+            elt.bind('click', lambda __: self.retry(notif_elt))
+
+
+
+
+notification = Notification()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/editor.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,234 @@
+"""text edition management"""
+
+from browser import document, window, aio, bind, timer
+from browser.local_storage import storage
+from browser.object_storage import ObjectStorage
+from javascript import JSON
+from bridge import AsyncBridge as Bridge, BridgeException
+from template import Template
+import dialog
+
+bridge = Bridge()
+object_storage = ObjectStorage(storage)
+profile = window.profile
+
+# how often we save forms, in seconds
+AUTOSAVE_FREQUENCY = 20
+
+
+def serialise_form(form_elt):
+    ret = {}
+    for elt in form_elt.elements:
+        if elt.tagName == "INPUT":
+            if elt.type in ("hidden", "submit"):
+                continue
+            elif elt.type == "text":
+                ret[elt.name] = elt.value
+            else:
+                print(f"elt.type not managet yet: {elt.type}")
+                continue
+        elif elt.tagName == "TEXTAREA":
+            ret[elt.name] = elt.value
+        elif elt.tagName in ("BUTTON",):
+            continue
+        else:
+            print(f"tag not managet yet: {elt.tagName}")
+            continue
+    return ret
+
+
+def restore_form(form_elt, data):
+    for elt in form_elt.elements:
+        if elt.tagName not in ("INPUT", "TEXTAREA"):
+            continue
+        try:
+            value = data[elt.name]
+        except KeyError:
+            continue
+        else:
+            elt.value = value
+
+
+def set_form_autosave(form_id):
+    """Save locally form data regularly and restore it until it's submitted
+
+    form is saved every AUTOSAVE_FREQUENCY seconds and when visibility is lost.
+    Saved data is restored when the method is called.
+    Saved data is cleared when the form is submitted.
+    """
+    if profile is None:
+        print(f"No session started, won't save and restore form {form_id}")
+        return
+
+    form_elt = document[form_id]
+    submitted = False
+
+    key = {"profile": profile, "type": "form_autosave", "form": form_id}
+    try:
+        form_saved_data = object_storage[key]
+    except KeyError:
+        last_serialised = None
+    else:
+        print(f"restoring content of form {form_id!r}")
+        last_serialised = form_saved_data
+        restore_form(form_elt, form_saved_data)
+
+    def save_form():
+        if not submitted:
+            nonlocal last_serialised
+            serialised = serialise_form(form_elt)
+            if serialised != last_serialised:
+                last_serialised = serialised
+                print(f"saving content of form {form_id!r}")
+                object_storage[key] = serialised
+
+    @bind(form_elt, "submit")
+    def on_submit(evt):
+        nonlocal submitted
+        submitted = True
+        print(f"clearing stored content of form {form_id!r}")
+        try:
+            del object_storage[key]
+        except KeyError:
+            print("key error")
+            pass
+
+    @bind(document, "visibilitychange")
+    def on_visibiliy_change(evt):
+        print("visibility change")
+        if document.visibilityState != "visible":
+            save_form()
+
+    timer.set_interval(save_form, AUTOSAVE_FREQUENCY * 1000)
+
+
+class TagsEditor:
+
+    def __init__(self, input_selector):
+        print("installing Tags Editor")
+        self.input_elt = document.select_one(input_selector)
+        self.input_elt.style.display = "none"
+        tags_editor_tpl = Template('editor/tags_editor.html')
+        self.tag_tpl = Template('editor/tag.html')
+
+        editor_elt = tags_editor_tpl.get_elt()
+        self.input_elt.parent <= editor_elt
+        self.tag_input_elt = editor_elt.select_one(".tag_input")
+        self.tag_input_elt.bind("keydown", self.on_key_down)
+        self._current_tags = None
+        self.tags_map = {}
+        for tag in self.current_tags:
+            self.add_tag(tag, force=True)
+
+    @property
+    def current_tags(self):
+        if self._current_tags is None:
+            self._current_tags = {
+                t.strip() for t in self.input_elt.value.split(',') if t.strip()
+            }
+        return self._current_tags
+
+    @current_tags.setter
+    def current_tags(self, tags):
+        self._current_tags = tags
+
+    def add_tag(self, tag, force=False):
+        tag = tag.strip()
+        if not force and (not tag or tag in self.current_tags):
+            return
+        self.current_tags = self.current_tags | {tag}
+        self.input_elt.value = ','.join(self.current_tags)
+        tag_elt = self.tag_tpl.get_elt({"label": tag})
+        self.tags_map[tag] = tag_elt
+        self.tag_input_elt.parent.insertBefore(tag_elt, self.tag_input_elt)
+        tag_elt.select_one(".click_to_delete").bind(
+            "click", lambda evt: self.on_tag_click(evt, tag)
+        )
+
+    def remove_tag(self, tag):
+        try:
+            tag_elt = self.tags_map[tag]
+        except KeyError:
+            print(f"trying to remove an inexistant tag: {tag}")
+        else:
+            self.current_tags = self.current_tags - {tag}
+            self.input_elt.value = ','.join(self.current_tags)
+            tag_elt.remove()
+
+    def on_tag_click(self, evt, tag):
+        evt.stopPropagation()
+        self.remove_tag(tag)
+
+    def on_key_down(self, evt):
+        if evt.key in (",", "Enter"):
+            evt.stopPropagation()
+            evt.preventDefault()
+            self.add_tag(self.tag_input_elt.value)
+            self.tag_input_elt.value = ""
+
+
+class BlogEditor:
+    """Editor class, handling tabs, preview, and submit loading button
+
+    It's using and HTML form as source
+    The form must have:
+        - a "title" text input
+        - a "body" textarea
+        - an optional "tags" text input with comma separated tags (may be using Tags
+          Editor)
+        - a "tab_preview" tab element
+    """
+
+    def __init__(self, form_id="blog_post_edit"):
+        self.tab_select = window.tab_select
+        self.item_tpl = Template('blog/item.html')
+        self.form = document[form_id]
+        for elt in document.select(".click_to_edit"):
+            elt.bind("click", self.on_edit)
+        for elt in document.select('.click_to_preview'):
+            elt.bind("click", lambda evt: aio.run(self.on_preview(evt)))
+        self.form.bind("submit", self.on_submit)
+
+
+    def on_edit(self, evt):
+        self.tab_select(evt.target, "tab_edit", "is-active")
+
+    async def on_preview(self, evt):
+        """Generate a blog preview from HTML form
+
+        """
+        print("on preview OK")
+        elt = evt.target
+        tab_preview = document["tab_preview"]
+        tab_preview.clear()
+        data = {
+            "content_rich": self.form.select_one('textarea[name="body"]').value.strip()
+        }
+        title = self.form.select_one('input[name="title"]').value.strip()
+        if title:
+            data["title_rich"] = title
+        tags_input_elt = self.form.select_one('input[name="tags"]')
+        if tags_input_elt is not None:
+            tags = tags_input_elt.value.strip()
+            if tags:
+                data['tags'] = [t.strip() for t in tags.split(',') if t.strip()]
+        try:
+            preview_data = JSON.parse(
+                await bridge.mb_preview("", "", JSON.stringify(data))
+            )
+        except BridgeException as e:
+            dialog.notification.show(
+                f"Can't generate item preview: {e.message}",
+                level="error"
+            )
+        else:
+            self.tab_select(elt, "tab_preview", "is-active")
+            item_elt = self.item_tpl.get_elt({
+                "item": preview_data,
+                "dates_format": "short",
+            })
+            tab_preview <= item_elt
+
+    def on_submit(self, evt):
+        submit_btn = document.select_one("button[type='submit']")
+        submit_btn.classList.add("is-loading")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/errors.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,2 @@
+class TimeoutError(Exception):
+    """An action has not been done in time"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/invitation.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,444 @@
+from browser import document, window, timer
+from bridge import Bridge
+from template import Template
+import dialog
+from cache import cache
+import javascript
+
+bridge = Bridge()
+# we use JS RegExp because Python's re is really long to import in Brython
+# FIXME: this is a naive JID regex, a more accurate should be used instead
+jid_re = javascript.RegExp.new(r"^\w+@\w+\.\w+")
+
+
+class InvitationManager:
+
+    def __init__(self, invitation_type, invitation_data):
+        self.invitation_type = invitation_type
+        self.invitation_data = invitation_data
+        manager_panel_tpl = Template('invitation/manager.html')
+        self.manager_panel_elt = manager_panel_tpl.get_elt()
+        self.invite_by_email_tpl = Template('invitation/invite_by_email.html')
+        self.affiliation_tpl = Template('invitation/affiliation_item.html')
+        self.new_item_tpl = Template('invitation/new_item.html')
+        # list of item passing filter when adding a new contact
+        self._filtered_new_items = {}
+        self._active_new_item = None
+        self._idx = 0
+
+    def attach(self, affiliations=None):
+        if affiliations is None:
+            affiliations = {}
+        self.affiliations = affiliations
+        self.side_panel = self.manager_panel_elt.select_one(
+            '.invitation_manager_side_panel')
+        self.open()
+        for close_elt in self.manager_panel_elt.select('.click_to_close'):
+            close_elt.bind("click", self.on_manager_close)
+        self.side_panel.bind("click", lambda evt: evt.stopPropagation())
+
+        cache.fill_identities(affiliations.keys(), callback=self._set_affiliations)
+
+        contact_elt = self.manager_panel_elt.select_one('input[name="contact"]')
+        contact_elt.bind("input", self.on_contact_input)
+        contact_elt.bind("keydown", self.on_contact_keydown)
+        contact_elt.bind("focus", self.on_contact_focus)
+        contact_elt.bind("blur", self.on_contact_blur)
+        document['invite_email'].bind('click', self.on_invite_email_click)
+
+    def _set_affiliations(self):
+        for entity_jid, affiliation in self.affiliations.items():
+            self.set_affiliation(entity_jid, affiliation)
+
+    def open(self):
+        """Re-attach and show a closed panel"""
+        self._body_ori_style = document.body.style.height, document.body.style.overflow
+        document.body.style.height = '100vh'
+        document.body.style.overflow = 'hidden'
+        document.body <= self.manager_panel_elt
+        timer.set_timeout(lambda: self.side_panel.classList.add("open"), 0)
+
+    def _on_close_transition_end(self, evt):
+        self.manager_panel_elt.remove()
+        # FIXME: not working with Brython, to report upstream
+        # self.side_panel.unbind("transitionend", self._on_close_transition_end)
+        self.side_panel.unbind("transitionend")
+
+    def close(self):
+        """Hide the panel"""
+        document.body.style.height, document.body.style.overflow = self._body_ori_style
+        self.side_panel.classList.remove('open')
+        self.side_panel.bind("transitionend", self._on_close_transition_end)
+
+    def _invite_jid(self, entity_jid, callback, errback=None):
+        if errback is None:
+            errback = lambda e: dialog.notification.show(f"invitation failed: {e}", "error")
+        if self.invitation_type == 'photos':
+            service = self.invitation_data["service"]
+            path = self.invitation_data["path"]
+            album_name = path.rsplit('/')[-1]
+            print(f"inviting {entity_jid}")
+            bridge.fis_invite(
+                entity_jid,
+                service,
+                "photos",
+                "",
+                path,
+                album_name,
+                '',
+                callback=callback,
+                errback=errback
+            )
+        elif self.invitation_type == 'pubsub':
+            service = self.invitation_data["service"]
+            node = self.invitation_data["node"]
+            name = self.invitation_data.get("name")
+            namespace = self.invitation_data.get("namespace")
+            extra = {}
+            if namespace:
+                extra["namespace"] = namespace
+            print(f"inviting {entity_jid}")
+            bridge.ps_invite(
+                entity_jid,
+                service,
+                node,
+                '',
+                name,
+                javascript.JSON.stringify(extra),
+                callback=callback,
+                errback=errback
+            )
+        else:
+            print(f"error: unknown invitation type: {self.invitation_type}")
+
+    def invite_by_jid(self, entity_jid):
+        self._invite_jid(
+            entity_jid,
+            callback=lambda entity_jid=entity_jid: self._on_jid_invitation_success(entity_jid),
+        )
+
+    def on_manager_close(self, evt):
+        self.close()
+
+    def _on_jid_invitation_success(self, entity_jid):
+        form_elt = document['invitation_form']
+        contact_elt = form_elt.select_one('input[name="contact"]')
+        contact_elt.value = ""
+        contact_elt.dispatchEvent(window.Event.new('input'))
+        dialog.notification.show(
+            f"{entity_jid} has been invited",
+            level="success",
+        )
+        if entity_jid not in self.affiliations:
+            self.set_affiliation(entity_jid, "member")
+
+    def on_contact_invite(self, evt, entity_jid):
+        """User is adding a contact"""
+        form_elt = document['invitation_form']
+        contact_elt = form_elt.select_one('input[name="contact"]')
+        contact_elt.value = ""
+        contact_elt.dispatchEvent(window.Event.new('input'))
+        self.invite_by_jid(entity_jid)
+
+    def on_contact_keydown(self, evt):
+        if evt.key == "Escape":
+            evt.target.value = ""
+            evt.target.dispatchEvent(window.Event.new('input'))
+        elif evt.key == "ArrowDown":
+            evt.stopPropagation()
+            evt.preventDefault()
+            content_elt = document['invitation_contact_search'].select_one(
+                ".search_dialog__content")
+            if self._active_new_item == None:
+                self._active_new_item = content_elt.firstElementChild
+                self._active_new_item.classList.add('selected')
+            else:
+                next_item = self._active_new_item.nextElementSibling
+                if next_item is not None:
+                    self._active_new_item.classList.remove('selected')
+                    self._active_new_item = next_item
+                    self._active_new_item.classList.add('selected')
+        elif evt.key == "ArrowUp":
+            evt.stopPropagation()
+            evt.preventDefault()
+            content_elt = document['invitation_contact_search'].select_one(
+                ".search_dialog__content")
+            if self._active_new_item == None:
+                self._active_new_item = content_elt.lastElementChild
+                self._active_new_item.classList.add('selected')
+            else:
+                previous_item = self._active_new_item.previousElementSibling
+                if previous_item is not None:
+                    self._active_new_item.classList.remove('selected')
+                    self._active_new_item = previous_item
+                    self._active_new_item.classList.add('selected')
+        elif evt.key == "Enter":
+            evt.stopPropagation()
+            evt.preventDefault()
+            if self._active_new_item is not None:
+                entity_jid = self._active_new_item.dataset.entityJid
+                self.invite_by_jid(entity_jid)
+            else:
+                if jid_re.exec(evt.target.value):
+                    self.invite_by_jid(evt.target.value)
+                    evt.target.value = ""
+
+    def on_contact_focus(self, evt):
+        search_dialog = document['invitation_contact_search']
+        search_dialog.classList.add('open')
+        self._active_new_item = None
+        evt.target.dispatchEvent(window.Event.new('input'))
+
+    def on_contact_blur(self, evt):
+        search_dialog = document['invitation_contact_search']
+        search_dialog.classList.remove('open')
+        for elt in self._filtered_new_items.values():
+            elt.remove()
+        self._filtered_new_items.clear()
+
+
+    def on_contact_input(self, evt):
+        text = evt.target.value.strip().lower()
+        search_dialog = document['invitation_contact_search']
+        content_elt = search_dialog.select_one(".search_dialog__content")
+        for (entity_jid, identity) in cache.identities.items():
+            if not cache.match_identity(entity_jid, text, identity):
+                # if the entity was present in last pass, we remove it
+                try:
+                    filtered_item = self._filtered_new_items.pop(entity_jid)
+                except KeyError:
+                    pass
+                else:
+                    filtered_item.remove()
+                continue
+            if entity_jid not in self._filtered_new_items:
+                # we only create a new element if the item was not already there
+                new_item_elt = self.new_item_tpl.get_elt({
+                    "entity_jid": entity_jid,
+                    "identities": cache.identities,
+                })
+                content_elt <= new_item_elt
+                self._filtered_new_items[entity_jid] = new_item_elt
+                for elt in new_item_elt.select('.click_to_ok'):
+                    # we use mousedown instead of click because otherwise it would be
+                    # ignored due to "blur" event manager (see
+                    # https://stackoverflow.com/a/9335401)
+                    elt.bind(
+                        "mousedown",
+                        lambda evt, entity_jid=entity_jid: self.on_contact_invite(
+                            evt, entity_jid),
+                    )
+
+        if ((self._active_new_item is not None
+             and not self._active_new_item.parentElement)):
+            # active item has been filtered out
+            self._active_new_item = None
+
+    def _on_email_invitation_success(self, invitee_jid, email, name):
+        self.set_affiliation(invitee_jid, "member")
+        dialog.notification.show(
+            f"{name} has been invited, he/she has received an email with a link",
+            level="success",
+        )
+
+    def invitation_simple_create_cb(self, invitation_data, email, name):
+        invitee_jid = invitation_data['jid']
+        self._invite_jid(
+            invitee_jid,
+            callback=lambda: self._on_email_invitation_success(invitee_jid, email, name),
+            errback=lambda e: dialog.notification.show(
+                f"invitation failed for {email}: {e}",
+                "error"
+            )
+        )
+
+        # we update identities to have the name instead of the invitation jid in
+        # affiliations
+        cache.identities[invitee_jid] = {'nicknames': [name]}
+        cache.update()
+
+    def invite_by_email(self, email, name):
+        guest_url_tpl = f'{window.URL.new("/g", document.baseURI).href}/{{uuid}}'
+        bridge.invitation_simple_create(
+            email,
+            name,
+            guest_url_tpl,
+            '',
+            callback=lambda data: self.invitation_simple_create_cb(data, email, name),
+            errback=lambda e: window.alert(f"can't send email invitation: {e}")
+        )
+
+    def on_invite_email_submit(self, evt, invite_email_elt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        form = document['email_invitation_form']
+        try:
+            reportValidity = form.reportValidity
+        except AttributeError:
+            print("reportValidity is not supported by this browser!")
+        else:
+            if not reportValidity():
+                return
+        email = form.select_one('input[name="email"]').value
+        name = form.select_one('input[name="name"]').value
+        self.invite_by_email(email, name)
+        invite_email_elt.remove()
+        self.open()
+
+    def on_invite_email_close(self, evt, invite_email_elt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        invite_email_elt.remove()
+        self.open()
+
+    def on_invite_email_click(self, evt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        invite_email_elt = self.invite_by_email_tpl.get_elt()
+        document.body <= invite_email_elt
+        document['email_invitation_submit'].bind(
+            'click', lambda evt: self.on_invite_email_submit(evt, invite_email_elt)
+        )
+        for close_elt in invite_email_elt.select('.click_to_close'):
+            close_elt.bind(
+                "click", lambda evt: self.on_invite_email_close(evt, invite_email_elt))
+        self.close()
+
+    ## affiliations
+
+    def _add_affiliation_bindings(self, entity_jid, affiliation_elt):
+        for elt in affiliation_elt.select(".click_to_delete"):
+            elt.bind(
+                "click",
+                lambda evt, entity_jid=entity_jid, affiliation_elt=affiliation_elt:
+                self.on_affiliation_remove(entity_jid, affiliation_elt)
+            )
+        for elt in affiliation_elt.select(".click_to_set_publisher"):
+            try:
+                name = cache.identities[entity_jid]["nicknames"][0]
+            except (KeyError, IndexError):
+                name = entity_jid
+            elt.bind(
+                "click",
+                lambda evt, entity_jid=entity_jid, name=name,
+                    affiliation_elt=affiliation_elt:
+                    self.on_affiliation_set(
+                        entity_jid, name, affiliation_elt, "publisher"
+                    ),
+            )
+        for elt in affiliation_elt.select(".click_to_set_member"):
+            try:
+                name = cache.identities[entity_jid]["nicknames"][0]
+            except (KeyError, IndexError):
+                name = entity_jid
+            elt.bind(
+                "click",
+                lambda evt, entity_jid=entity_jid, name=name,
+                    affiliation_elt=affiliation_elt:
+                    self.on_affiliation_set(
+                        entity_jid, name, affiliation_elt, "member"
+                    ),
+            )
+
+    def set_affiliation(self, entity_jid, affiliation):
+        if affiliation not in ('owner', 'member', 'publisher'):
+            raise NotImplementedError(
+                f'{affiliation} affiliation can not be set with this method for the '
+                'moment')
+        if entity_jid not in self.affiliations:
+            self.affiliations[entity_jid] = affiliation
+        affiliation_elt = self.affiliation_tpl.get_elt({
+            "entity_jid": entity_jid,
+            "affiliation": affiliation,
+            "identities": cache.identities,
+        })
+        document['affiliations'] <= affiliation_elt
+        self._add_affiliation_bindings(entity_jid, affiliation_elt)
+
+    def _on_affiliation_remove_success(self, affiliation_elt, entity_jid):
+        affiliation_elt.remove()
+        del self.affiliations[entity_jid]
+
+    def on_affiliation_remove(self, entity_jid, affiliation_elt):
+        if self.invitation_type == 'photos':
+            path = self.invitation_data["path"]
+            service = self.invitation_data["service"]
+            bridge.fis_affiliations_set(
+                service,
+                "",
+                path,
+                {entity_jid: "none"},
+                callback=lambda: self._on_affiliation_remove_success(
+                    affiliation_elt, entity_jid),
+                errback=lambda e: dialog.notification.show(
+                    f"can't remove affiliation: {e}", "error")
+            )
+        elif self.invitation_type == 'pubsub':
+            service = self.invitation_data["service"]
+            node = self.invitation_data["node"]
+            bridge.ps_node_affiliations_set(
+                service,
+                node,
+                {entity_jid: "none"},
+                callback=lambda: self._on_affiliation_remove_success(
+                    affiliation_elt, entity_jid),
+                errback=lambda e: dialog.notification.show(
+                    f"can't remove affiliation: {e}", "error")
+            )
+        else:
+            dialog.notification.show(
+                f"error: unknown invitation type: {self.invitation_type}",
+                "error"
+            )
+
+    def _on_affiliation_set_success(self, entity_jid, name, affiliation_elt, affiliation):
+        dialog.notification.show(f"permission updated for {name}")
+        self.affiliations[entity_jid] = affiliation
+        new_affiliation_elt = self.affiliation_tpl.get_elt({
+            "entity_jid": entity_jid,
+            "affiliation": affiliation,
+            "identities": cache.identities,
+        })
+        affiliation_elt.replaceWith(new_affiliation_elt)
+        self._add_affiliation_bindings(entity_jid, new_affiliation_elt)
+
+    def _on_affiliation_set_ok(self, entity_jid, name, affiliation_elt, affiliation):
+        if self.invitation_type == 'pubsub':
+            service = self.invitation_data["service"]
+            node = self.invitation_data["node"]
+            bridge.ps_node_affiliations_set(
+                service,
+                node,
+                {entity_jid: affiliation},
+                callback=lambda: self._on_affiliation_set_success(
+                    entity_jid, name, affiliation_elt, affiliation
+                ),
+                errback=lambda e: dialog.notification.show(
+                    f"can't set affiliation: {e}", "error")
+            )
+        else:
+            dialog.notification.show(
+                f"error: unknown invitation type: {self.invitation_type}",
+                "error"
+            )
+
+    def _on_affiliation_set_cancel(self, evt, notif_elt):
+        notif_elt.remove()
+        self.open()
+
+    def on_affiliation_set(self, entity_jid, name, affiliation_elt, affiliation):
+        if affiliation == "publisher":
+            message = f"Give autorisation to publish to {name}?"
+        elif affiliation == "member":
+            message = f"Remove autorisation to publish from {name}?"
+        else:
+            dialog.notification.show(f"unmanaged affiliation: {affiliation}", "error")
+            return
+        dialog.Confirm(message).show(
+            ok_cb=lambda evt, notif_elt:
+                self._on_affiliation_set_ok(
+                    entity_jid, name, affiliation_elt, affiliation
+                ),
+            cancel_cb=self._on_affiliation_set_cancel
+        )
+        self.close()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/loading.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,7 @@
+"""manage common dialogs"""
+
+from browser import document
+
+def remove_loading_screen():
+    print("removing loading screen")
+    document['loading_screen'].remove()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/slideshow.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,262 @@
+from browser import document, window, html, timer, DOMNode
+from js_modules.swiper import Swiper
+from template import Template
+
+
+class SlideShow:
+
+    def __init__(self):
+        self.swiper = None
+        slideshow_tpl = Template('photo/slideshow.html')
+        self.slideshow_elt = slideshow_tpl.get_elt()
+        self.comments_count_elt = self.slideshow_elt.select_one('.comments__count')
+        self.wrapper = self.slideshow_elt.select_one(".swiper-wrapper")
+        self.hidden_elts = {}
+        self.control_hidden = False
+        self.click_timer = None
+        self._class_to_remove = set()
+        self._exit_callback = None
+
+    @property
+    def current_slide(self):
+        if self.swiper is None:
+            return None
+        try:
+            return DOMNode(self.swiper.slides[self.swiper.realIndex])
+        # getting missing item in JS arrays returns KeyError
+        except KeyError:
+            return None
+
+    @property
+    def current_item(self):
+        """item attached to the current slide, if any"""
+        current = self.current_slide
+        if current is None:
+            return None
+        try:
+            return current._item
+        except AttributeError:
+            return None
+
+    @property
+    def current_options(self):
+        """options attached to the current slide, if any"""
+        current = self.current_slide
+        if current is None:
+            return None
+        try:
+            return current._options
+        except AttributeError:
+            return None
+
+    @property
+    def index(self):
+        if self.swiper is None:
+            return None
+        return self.swiper.realIndex
+
+    @index.setter
+    def index(self, idx):
+        if self.swiper is not None:
+            self.swiper.slideTo(idx, 0)
+
+    def attach(self):
+        # we hide other elts to avoid scrolling issues
+        for elt in document.body.children:
+            try:
+                self.hidden_elts[elt] = elt.style.display
+            except AttributeError:
+                pass
+                # FIXME: this is a workaround needed because Brython's children method
+                #   is returning all nodes,
+                #   cf. https://github.com/brython-dev/brython/issues/1657
+                #   to be removed when Brython is fixed.
+            else:
+                elt.style.display = "none"
+        document.body <= self.slideshow_elt
+        self.swiper = Swiper.new(
+            ".swiper-container",
+            {
+                # default 0 value results in lot of accidental swipes, notably when media
+                # player is used
+                "threshold": 10,
+                "pagination": {
+                    "el": ".swiper-pagination",
+                },
+                "navigation": {
+                    "nextEl": ".swiper-button-next",
+                    "prevEl": ".swiper-button-prev",
+                },
+                "scrollbar": {
+                    "el": ".swiper-scrollbar",
+                },
+                "grabCursor": True,
+                "keyboard": {
+                    "enabled": True,
+                    "onlyInViewport": False,
+                },
+                "mousewheel": True,
+                "zoom": {
+                    "maxRatio": 15,
+                    "toggle": False,
+                },
+            }
+        )
+        window.addEventListener("keydown", self.on_key_down, True)
+        self.slideshow_elt.select_one(".click_to_close").bind("click", self.on_close)
+        self.slideshow_elt.select_one(".click_to_comment").bind("click", self.on_comment)
+
+        # we don't use swiper.on for "click" and "dblclick" (or "doubleTap" in swiper
+        # terms) because it breaks event propagation management, which cause trouble with
+        # media player
+        self.slideshow_elt.bind("click", self.on_click)
+        self.slideshow_elt.bind("dblclick", self.on_dblclick)
+        self.swiper.on("slideChange", self.on_slide_change)
+        self.on_slide_change(self.swiper)
+        self.fullscreen(True)
+
+    def add_slide(self, elt, item_data=None, options=None):
+        slide_elt = html.DIV(Class="swiper-slide")
+        zoom_cont_elt = html.DIV(Class="swiper-zoom-container")
+        slide_elt <= zoom_cont_elt
+        zoom_cont_elt <= elt
+        slide_elt._item = item_data
+        if options is not None:
+            slide_elt._options = options
+        self.swiper.appendSlide([slide_elt])
+        self.swiper.update()
+
+    def quit(self):
+        # we unhide
+        for elt, display in self.hidden_elts.items():
+            elt.style.display = display
+        self.hidden_elts.clear()
+        self.slideshow_elt.remove()
+        self.slideshow_elt = None
+        self.swiper.destroy(True, True)
+        self.swiper = None
+
+    def fullscreen(self, active=None):
+        """Activate/desactivate fullscreen
+
+        @param acvite: can be:
+            - True to activate
+            - False to desactivate
+            - Auto to switch fullscreen mode
+        """
+        try:
+            fullscreen_elt = document.fullscreenElement
+            request_fullscreen = self.slideshow_elt.requestFullscreen
+        except AttributeError:
+            print("fullscreen is not available on this browser")
+        else:
+            if active is None:
+                active = fullscreen_elt == None
+            if active:
+                request_fullscreen()
+            else:
+                try:
+                    document.exitFullscreen()
+                except AttributeError:
+                    print("exitFullscreen not available on this browser")
+
+    def on_key_down(self, evt):
+        if evt.key == 'Escape':
+            self.quit()
+        else:
+            return
+        evt.preventDefault()
+
+    def on_slide_change(self, swiper):
+        if self._exit_callback is not None:
+            self._exit_callback()
+            self._exit_callback = None
+        item = self.current_item
+        if item is not None:
+            comments_count = item.get('comments_count')
+            self.comments_count_elt.text = comments_count or ''
+
+        for cls in self._class_to_remove:
+            self.slideshow_elt.classList.remove(cls)
+
+        self._class_to_remove.clear()
+
+        options = self.current_options
+        if options is not None:
+            for flag in options.get('flags', []):
+                cls = f"flag_{flag.lower()}"
+                self.slideshow_elt.classList.add(cls)
+                self._class_to_remove.add(cls)
+            self._exit_callback = options.get("exit_callback", None)
+
+    def toggle_hide_controls(self, evt):
+        self.click_timer = None
+        # we don't want to hide controls when a control is clicked
+        # so we check all ancestors if we are not in a control
+        current = evt.target
+        while current and current != self.slideshow_elt:
+            print(f"current: {current}")
+            if 'slideshow_control' in current.classList:
+                return
+            current = current.parent
+        for elt in self.slideshow_elt.select('.slideshow_control'):
+            elt.style.display = '' if self.control_hidden else 'none'
+        self.control_hidden = not self.control_hidden
+
+    def on_click(self, evt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        # we use a timer so double tap can cancel the click
+        # this avoid double tap side effect
+        if self.click_timer is None:
+            self.click_timer = timer.set_timeout(
+                lambda: self.toggle_hide_controls(evt), 300)
+
+    def on_dblclick(self, evt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        if self.click_timer is not None:
+            timer.clear_timeout(self.click_timer)
+            self.click_timer = None
+        if self.swiper.zoom.scale != 1:
+            self.swiper.zoom.toggle()
+        else:
+            # "in" is reserved in Python, so we call it using dict syntax
+            self.swiper.zoom["in"]()
+
+    def on_close(self, evt):
+        evt.stopPropagation()
+        evt.preventDefault()
+        self.quit()
+
+    def on_comment_close(self, evt):
+        evt.stopPropagation()
+        side_panel = self.comments_panel_elt.select_one('.comments_side_panel')
+        side_panel.classList.remove('open')
+        side_panel.bind("transitionend", lambda evt: self.comments_panel_elt.remove())
+
+    def on_comments_panel_click(self, evt):
+        # we stop stop propagation to avoid the closing of the panel
+        evt.stopPropagation()
+
+    def on_comment(self, evt):
+        item = self.current_item
+        if item is None:
+            return
+        comments_panel_tpl = Template('blog/comments_panel.html')
+        try:
+            comments = item['comments']['items']
+        except KeyError:
+            comments = []
+        self.comments_panel_elt = comments_panel_tpl.get_elt({
+            "comments": comments,
+            "comments_service": item['comments_service'],
+            "comments_node": item['comments_node'],
+
+        })
+        self.slideshow_elt <= self.comments_panel_elt
+        side_panel = self.comments_panel_elt.select_one('.comments_side_panel')
+        timer.set_timeout(lambda: side_panel.classList.add("open"), 0)
+        for close_elt in self.comments_panel_elt.select('.click_to_close'):
+            close_elt.bind("click", self.on_comment_close)
+        side_panel.bind("click", self.on_comments_panel_click)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/template.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,235 @@
+"""Integrate templating system using nunjucks"""
+
+from js_modules.nunjucks import nunjucks
+from browser import window, document
+import javascript
+
+
+safe = nunjucks.runtime.SafeString.new
+env = nunjucks.configure(
+    window.templates_root_url,
+    {
+        'autoescape': True,
+        'trimBlocks': True,
+        'lstripBlocks': True,
+        'web': {'use_cache': True},
+    })
+
+nunjucks.installJinjaCompat()
+env.addGlobal("profile", window.profile)
+env.addGlobal("csrf_token", window.csrf_token)
+# FIXME: integrate gettext or equivalent here
+env.addGlobal("_", lambda txt: txt)
+
+
+class Indexer:
+    """Index global to a page"""
+
+    def __init__(self):
+        self._indexes = {}
+
+    def next(self, value):
+        if value not in self._indexes:
+            self._indexes[value] = 0
+            return 0
+        self._indexes[value] += 1
+        return self._indexes[value]
+
+    def current(self, value):
+        return self._indexes.get(value)
+
+
+gidx = Indexer()
+# suffix use to avoid collision with IDs generated in static page
+SCRIPT_SUFF = "__script__"
+
+def escape_html(txt):
+    return (
+        txt
+        .replace('&', '&amp;')
+        .replace('<', '&lt;')
+        .replace('>', '&gt;')
+        .replace('"', '&quot;')
+    )
+
+
+def get_args(n_args, *sig_args, **sig_kwargs):
+    """Retrieve function args when they are transmitted using nunjucks convention
+
+    cf. https://mozilla.github.io/nunjucks/templating.html#keyword-arguments
+    @param n_args: argument from nunjucks call
+    @param sig_args: expected positional arguments
+    @param sig_kwargs: expected keyword arguments
+    @return: all expected arguments, with default value if not specified in nunjucks
+    """
+    # nunjucks set kwargs in last argument
+    given_args = list(n_args)
+    try:
+        given_kwargs = given_args.pop().to_dict()
+    except (AttributeError, IndexError):
+        # we don't have a dict as last argument
+        # that happens when there is no keyword argument
+        given_args = list(n_args)
+        given_kwargs = {}
+    ret = given_args[:len(sig_args)]
+    # we check if we have remaining positional arguments
+    # in which case they may be specified in keyword arguments
+    for name in sig_args[len(given_args):]:
+        try:
+            value = given_kwargs.pop(name)
+        except KeyError:
+            raise ValueError(f"missing positional arguments {name!r}")
+        ret.append(value)
+
+    extra_pos_args = given_args[len(sig_args):]
+    # and now the keyword arguments
+    for name, default in sig_kwargs.items():
+        if extra_pos_args:
+            # kw args has been specified with a positional argument
+            ret.append(extra_pos_args.pop(0))
+            continue
+        value = given_kwargs.get(name, default)
+        ret.append(value)
+
+    return ret
+
+
+def _next_gidx(value):
+    """Use next current global index as suffix"""
+    next_ = gidx.next(value)
+    return f"{value}{SCRIPT_SUFF}" if next_ == 0 else f"{value}_{SCRIPT_SUFF}{next_}"
+
+env.addFilter("next_gidx", _next_gidx)
+
+
+def _cur_gidx(value):
+    """Use current current global index as suffix"""
+    current = gidx.current(value)
+    return f"{value}{SCRIPT_SUFF}" if not current else f"{value}_{SCRIPT_SUFF}{current}"
+
+env.addFilter("cur_gidx", _cur_gidx)
+
+
+def _xmlattr(d, autospace=True):
+    if not d:
+        return
+    d = d.to_dict()
+    ret = [''] if autospace else []
+    for key, value in d.items():
+        if value is not None:
+            ret.append(f'{escape_html(key)}="{escape_html(str(value))}"')
+
+    return safe(' '.join(ret))
+
+env.addFilter("xmlattr", _xmlattr)
+
+
+def _tojson(value):
+    return safe(escape_html(window.JSON.stringify(value)))
+
+env.addFilter("tojson", _tojson)
+
+
+def _icon_use(name, cls=""):
+    kwargs = cls.to_dict()
+    cls = kwargs.get('cls')
+    return safe(
+        '<svg class="svg-icon{cls}" xmlns="http://www.w3.org/2000/svg" '
+        'viewBox="0 0 100 100">\n'
+        '    <use href="#{name}"/>'
+        '</svg>\n'.format(name=name, cls=(" " + cls) if cls else "")
+    )
+
+env.addGlobal("icon", _icon_use)
+
+
+def _date_fmt(
+    timestamp, *args
+):
+    """Date formatting
+
+    cf. libervia.backend.tools.common.date_utils for arguments details
+    """
+    fmt, date_only, auto_limit, auto_old_fmt, auto_new_fmt = get_args(
+        args, fmt="short", date_only=False, auto_limit=7, auto_old_fmt="short",
+        auto_new_fmt="relative",
+    )
+    from js_modules.moment import moment
+    date = moment.unix(timestamp)
+
+    if fmt == "auto_day":
+        fmt, auto_limit, auto_old_fmt, auto_new_fmt = "auto", 0, "short", "HH:mm"
+    if fmt == "auto":
+        limit = moment().startOf('day').subtract(auto_limit, 'days')
+        m_fmt = auto_old_fmt if date < limit else auto_new_fmt
+
+    if fmt == "short":
+        m_fmt = "DD/MM/YY" if date_only else "DD/MM/YY HH:mm"
+    elif fmt == "medium":
+        m_fmt = "ll" if date_only else "lll"
+    elif fmt == "long":
+        m_fmt = "LL" if date_only else "LLL"
+    elif fmt == "full":
+        m_fmt = "dddd, LL" if date_only else "LLLL"
+    elif fmt == "relative":
+        return date.fromNow()
+    elif fmt == "iso":
+        if date_only:
+            m_fmt == "YYYY-MM-DD"
+        else:
+            return date.toISOString()
+    else:
+        raise NotImplementedError("free format is not implemented yet")
+
+    return date.format(m_fmt)
+
+env.addFilter("date_fmt", _date_fmt)
+
+
+class I18nExtension:
+    """Extension to handle the {% trans %}{% endtrans %} statement"""
+    # FIXME: for now there is no translation, this extension only returns the string
+    #        unmodified
+    tags = ['trans']
+
+    def parse(self, parser, nodes, lexer):
+        tok = parser.nextToken()
+        args = parser.parseSignature(None, True)
+        parser.advanceAfterBlockEnd(tok.value)
+        body = parser.parseUntilBlocks('endtrans')
+        parser.advanceAfterBlockEnd()
+        return nodes.CallExtension.new(self._js_ext, 'run', args, [body])
+
+    def run(self, context, *args):
+        body = args[-1]
+        return body()
+
+    @classmethod
+    def install(cls, env):
+        ext = cls()
+        ext_dict = {
+            "tags": ext.tags,
+            "parse": ext.parse,
+            "run": ext.run
+        }
+        ext._js_ext = javascript.pyobj2jsobj(ext_dict)
+        env.addExtension(cls.__name__, ext._js_ext)
+
+I18nExtension.install(env)
+
+
+class Template:
+
+    def __init__(self, tpl_name):
+        self._tpl = env.getTemplate(tpl_name, True)
+
+    def render(self, context):
+        return self._tpl.render(context)
+
+    def get_elt(self, context=None):
+        if context is None:
+            context = {}
+        raw_html = self.render(context)
+        template_elt = document.createElement('template')
+        template_elt.innerHTML = raw_html
+        return template_elt.content.firstElementChild
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/_browser/tmp_aio.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,26 @@
+from browser import window
+
+"""Q&D module to do ajax requests with data types currently unsupported by Brython"""
+# FIXME: remove this module when official aio module allows to work with blobs
+
+window.eval("""
+var _tmp_ajax = function(method, url, format, data){
+    return new Promise(function(resolve, reject){
+        var xhr = new XMLHttpRequest()
+        xhr.open(method, url, true)
+        xhr.responseType = format
+        xhr.onreadystatechange = function(){
+            if(this.readyState == 4){
+                resolve(this)
+            }
+        }
+        if(data){
+            xhr.send(data)
+        }else{
+            xhr.send()
+        }
+    })
+}
+""")
+
+ajax = window._tmp_ajax
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/app/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,5 @@
+#!/usr/bin/env python3
+
+
+name = "app"
+template = "app/app.html"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/blog/edit/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,6 @@
+import editor
+
+
+editor.set_form_autosave("blog_post_edit")
+editor.BlogEditor()
+editor.TagsEditor("input[name='tags']")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/blog/edit/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+name = "blog_edit"
+access = C.PAGES_ACCESS_PROFILE
+template = "blog/publish.html"
+
+
+async def on_data_post(self, request):
+    profile = self.get_profile(request)
+    if profile is None:
+        self.page_error(request, C.HTTP_FORBIDDEN)
+    request_data = self.get_r_data(request)
+    title, tags, body = self.get_posted_data(request, ('title', 'tags', 'body'))
+    mb_data = {"content_rich": body, "allow_comments": True}
+    title = title.strip()
+    if title:
+        mb_data["title_rich"] = title
+    tags = [t.strip() for t in tags.split(',') if t.strip()]
+    if tags:
+        mb_data["tags"] = tags
+
+    await self.host.bridge_call(
+        'mb_send',
+        "",
+        "",
+        data_format.serialise(mb_data),
+        profile
+    )
+
+    request_data["post_redirect_page"] = self.get_page_by_name("blog")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/blog/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+
+from libervia.backend.core.i18n import _
+from libervia.web.server.constants import Const as C
+from twisted.words.protocols.jabber import jid
+from twisted.internet import defer
+from libervia.web.server import session_iface
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+name = "blog"
+access = C.PAGES_ACCESS_PUBLIC
+template = "blog/discover.html"
+
+
+async def prepare_render(self, request):
+    profile = self.get_profile(request)
+    template_data = request.template_data
+    if profile is not None:
+        __, entities_own, entities_roster = await self.host.bridge_call(
+            "disco_find_by_features",
+            [],
+            [("pubsub", "pep")],
+            True,
+            False,
+            True,
+            True,
+            True,
+            profile,
+        )
+        entities = template_data["disco_entities"] = (
+            list(entities_own.keys()) + list(entities_roster.keys())
+        )
+        entities_url = template_data["entities_url"] = {}
+        identities = self.host.get_session_data(
+            request, session_iface.IWebSession
+        ).identities
+        d_list = {}
+        for entity_jid_s in entities:
+            entities_url[entity_jid_s] = self.get_page_by_name("blog_view").get_url(
+                entity_jid_s
+            )
+            if entity_jid_s not in identities:
+                d_list[entity_jid_s] = self.host.bridge_call(
+                        "identity_get",
+                        entity_jid_s,
+                        [],
+                        True,
+                        profile)
+        identities_data = await defer.DeferredList(d_list.values())
+        entities_idx = list(d_list.keys())
+        for idx, (success, identity_raw) in enumerate(identities_data):
+            entity_jid_s = entities_idx[idx]
+            if not success:
+                log.warning(_("Can't retrieve identity of {entity}")
+                    .format(entity=entity_jid_s))
+            else:
+                identities[entity_jid_s] = data_format.deserialise(identity_raw)
+
+        template_data["url_blog_edit"] = self.get_sub_page_url(request, "blog_edit")
+
+
+def on_data_post(self, request):
+    jid_str = self.get_posted_data(request, "jid")
+    try:
+        jid_ = jid.JID(jid_str)
+    except RuntimeError:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    url = self.get_page_by_name("blog_view").get_url(jid_.full())
+    self.http_redirect(request, url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/blog/view/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,6 @@
+#!/usr/bin/env python3
+
+import alt_media_player
+
+
+alt_media_player.install_if_needed()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/blog/view/atom.xml/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,38 @@
+#!/usr/bin/env python3
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.tools.common import uri
+import time
+
+name = "blog_feed_atom"
+access = C.PAGES_ACCESS_PUBLIC
+template = "blog/atom.xml"
+
+
+async def prepare_render(self, request):
+    request.setHeader("Content-Type", "application/atom+xml; charset=utf-8")
+    data = self.get_r_data(request)
+    service, node = data["service"], data.get("node")
+    self.check_cache(
+        request, C.CACHE_PUBSUB, service=service, node=node, short="microblog"
+    )
+    data["show_comments"] = False
+    template_data = request.template_data
+    blog_page = self.get_page_by_name("blog_view")
+    await blog_page.prepare_render(self, request)
+    items = data["blog_items"]['items']
+
+    template_data["request_uri"] = self.host.get_ext_base_url(
+        request, request.path.decode("utf-8")
+    )
+    template_data["xmpp_uri"] = uri.build_xmpp_uri(
+        "pubsub", subtype="microblog", path=service.full(), node=node
+    )
+    blog_view = self.get_page_by_name("blog_view")
+    template_data["http_uri"] = self.host.get_ext_base_url(
+        request, blog_view.get_url(service.full(), node)
+    )
+    if items:
+        template_data["updated"] = items[0]['updated']
+    else:
+        template_data["updated"] = time.time()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/blog/view/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,381 @@
+#!/usr/bin/env python3
+
+import html
+from typing import Any, Dict, Optional
+
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common.template import safe
+from twisted.web import server
+from twisted.words.protocols.jabber import jid
+
+from libervia.web.server import utils
+from libervia.web.server.constants import Const as C
+from libervia.web.server.utils import SubPage
+
+log = getLogger(__name__)
+
+"""generic blog (with service/node provided)"""
+name = 'blog_view'
+template = "blog/articles.html"
+uri_handlers = {('pubsub', 'microblog'): 'microblog_uri'}
+
+URL_LIMIT_MARK = 90  # if canonical URL is longer than that, text will not be appended
+
+
+def microblog_uri(self, uri_data):
+    args = [uri_data['path'], uri_data['node']]
+    if 'item' in uri_data:
+        args.extend(['id', uri_data['item']])
+    return self.get_url(*args)
+
+def parse_url(self, request):
+    """URL is /[service]/[node]/[filter_keyword]/[item]|[other]
+
+    if [node] is '@', default namespace is used
+    if a value is unset, default one will be used
+    keyword can be one of:
+        id: next value is a item id
+        tag: next value is a blog tag
+    """
+    data = self.get_r_data(request)
+
+    try:
+        service = self.next_path(request)
+    except IndexError:
+        data['service'] = ''
+    else:
+        try:
+            data["service"] = jid.JID(service)
+        except Exception:
+            log.warning(_("bad service entered: {}").format(service))
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    try:
+        node = self.next_path(request)
+    except IndexError:
+        node = '@'
+    data['node'] = '' if node == '@' else node
+
+    try:
+        filter_kw = data['filter_keyword'] = self.next_path(request)
+    except IndexError:
+        filter_kw = '@'
+    else:
+        if filter_kw == '@':
+            # No filter, this is used when a subpage is needed, notably Atom feed
+            pass
+        elif filter_kw == 'id':
+            try:
+                data['item'] = self.next_path(request)
+            except IndexError:
+                self.page_error(request, C.HTTP_BAD_REQUEST)
+            # we get one more argument in case text has been added to have a nice URL
+            try:
+                self.next_path(request)
+            except IndexError:
+                pass
+        elif filter_kw == 'tag':
+            try:
+                data['tag'] = self.next_path(request)
+            except IndexError:
+                self.page_error(request, C.HTTP_BAD_REQUEST)
+        else:
+            # invalid filter keyword
+            log.warning(_("invalid filter keyword: {filter_kw}").format(
+                filter_kw=filter_kw))
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    # if URL is parsed here, we'll have atom.xml available and we need to
+    # add the link to the page
+    atom_url = self.get_url_by_path(
+        SubPage('blog_view'),
+        service,
+        node,
+        filter_kw,
+        SubPage('blog_feed_atom'),
+    )
+    request.template_data['atom_url'] = atom_url
+    request.template_data.setdefault('links', []).append({
+        "href": atom_url,
+        "type": "application/atom+xml",
+        "rel": "alternate",
+        "title": "{service}'s blog".format(service=service)})
+
+
+def add_breadcrumb(self, request, breadcrumbs):
+    data = self.get_r_data(request)
+    breadcrumbs.append({
+        "label": D_("Feed"),
+        "url": self.get_url(data["service"].full(), data.get("node", "@"))
+    })
+    if "item" in data:
+        breadcrumbs.append({
+            "label": D_("Post"),
+        })
+
+
+async def append_comments(
+    self,
+    request: server.Request,
+    blog_items: dict,
+    profile: str,
+    _seen: Optional[set] = None
+) -> None:
+    """Recursively download and append comments of items
+
+    @param blog_items: items data
+    @param profile: Libervia profile
+    @param _seen: used to avoid infinite recursion. For internal use only
+    """
+    if _seen is None:
+        _seen = set()
+    await self.fill_missing_identities(
+        request, [i['author_jid'] for i in blog_items['items']])
+    extra: Dict[str, Any] = {C.KEY_ORDER_BY: C.ORDER_BY_CREATION}
+    if not self.use_cache(request):
+        extra[C.KEY_USE_CACHE] = False
+    for blog_item in blog_items['items']:
+        for comment_data in blog_item['comments']:
+            service = comment_data['service']
+            node = comment_data['node']
+            service_node = (service, node)
+            if service_node in _seen:
+                log.warning(
+                    f"Items from {node!r} at {service} have already been retrieved, "
+                    "there is a recursion at this service"
+                )
+                comment_data["items"] = []
+                continue
+            else:
+                _seen.add(service_node)
+            try:
+                comments_data = await self.host.bridge_call('mb_get',
+                                      service,
+                                      node,
+                                      C.NO_LIMIT,
+                                      [],
+                                      data_format.serialise(
+                                          extra
+                                      ),
+                                      profile)
+            except Exception as e:
+                log.warning(
+                    _("Can't get comments at {node} (service: {service}): {msg}").format(
+                        service=service,
+                        node=node,
+                        msg=e))
+                comment_data['items'] = []
+                continue
+
+            comments = data_format.deserialise(comments_data)
+            if comments is None:
+                log.error(f"Comments should not be None: {comment_data}")
+                comment_data["items"] = []
+                continue
+            comment_data['items'] = comments['items']
+            await append_comments(self, request, comments, profile, _seen=_seen)
+
+async def get_blog_items(
+    self,
+    request: server.Request,
+    service: jid.JID,
+    node: str,
+    item_id,
+    extra: Dict[str, Any],
+    profile: str
+) -> dict:
+    try:
+        if item_id:
+            items_id = [item_id]
+        else:
+            items_id = []
+        if not self.use_cache(request):
+            extra[C.KEY_USE_CACHE] = False
+        blog_data = await self.host.bridge_call('mb_get',
+                              service.userhost(),
+                              node,
+                              C.NO_LIMIT,
+                              items_id,
+                              data_format.serialise(extra),
+                              profile)
+    except Exception as e:
+        # FIXME: need a better way to test errors in bridge errback
+        if "forbidden" in str(e):
+            self.page_error(request, 401)
+        else:
+            log.warning(_("can't retrieve blog for [{service}]: {msg}".format(
+                service = service.userhost(), msg=e)))
+            blog_data = {"items": []}
+    else:
+        blog_data = data_format.deserialise(blog_data)
+
+    return blog_data
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    page_max = data.get("page_max", 10)
+    # if the comments are not explicitly hidden, we show them
+    service, node, item_id, show_comments = (
+        data.get('service', ''),
+        data.get('node', ''),
+        data.get('item'),
+        data.get('show_comments', True)
+    )
+    profile = self.get_profile(request)
+    if profile is None:
+        profile = C.SERVICE_PROFILE
+        profile_connected = False
+    else:
+        profile_connected = True
+
+    ## pagination/filtering parameters
+    if item_id:
+        extra = {}
+    else:
+        extra = self.get_pubsub_extra(request, page_max=page_max)
+        tag = data.get('tag')
+        if tag:
+            extra[f'mam_filter_{C.MAM_FILTER_CATEGORY}'] = tag
+        self.handle_search(request, extra)
+
+    ## main data ##
+    # we get data from backend/XMPP here
+    blog_items = await get_blog_items(self, request, service, node, item_id, extra, profile)
+
+    ## navigation ##
+    # no let's fill service, node and pagination URLs
+    if 'service' not in template_data:
+        template_data['service'] = service
+    if 'node' not in template_data:
+        template_data['node'] = node
+    target_profile = template_data.get('target_profile')
+
+    if blog_items:
+        if item_id:
+            template_data["previous_page_url"] = self.get_url(
+                service.full(),
+                node,
+                before=item_id,
+                page_max=1
+            )
+            template_data["next_page_url"] = self.get_url(
+                service.full(),
+                node,
+                after=item_id,
+                page_max=1
+            )
+            blog_items["rsm"] = {
+                "last": item_id,
+                "first": item_id,
+            }
+            blog_items["complete"] = False
+        else:
+            self.set_pagination(request, blog_items)
+    else:
+        if item_id:
+            # if item id has been specified in URL and it's not found,
+            # we must return an error
+            self.page_error(request, C.HTTP_NOT_FOUND)
+
+    ## identities ##
+    # identities are used to show nice nickname or avatars
+    await self.fill_missing_identities(request, [i['author_jid'] for i in blog_items['items']])
+
+    ## Comments ##
+    # if comments are requested, we need to take them
+    if show_comments:
+        await append_comments(self, request, blog_items, profile)
+
+    ## URLs ##
+    # We will fill items_http_uri and tags_http_uri in template_data with suitable urls
+    # if we know the profile, we use it instead of service + blog (nicer url)
+    if target_profile is None:
+        blog_base_url_item = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'id')
+        blog_base_url_tag = self.get_page_by_name('blog_view').get_url(service.full(), node or '@', 'tag')
+    else:
+        blog_base_url_item = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['id'])])
+        blog_base_url_tag = self.get_url_by_names([('user', [target_profile]), ('user_blog', ['tag'])])
+        # we also set the background image if specified by user
+        bg_img = await self.host.bridge_call('param_get_a_async', 'Background', 'Blog page', 'value', -1, template_data['target_profile'])
+        if bg_img:
+            template_data['dynamic_style'] = safe("""
+                :root {
+                    --bg-img: url("%s");
+                }
+                """ % html.escape(bg_img, True))
+
+    template_data['blog_items'] = data['blog_items'] = blog_items
+    if request.args.get(b'reverse') == ['1']:
+        template_data['blog_items'].items.reverse()
+    template_data['items_http_uri'] = items_http_uri = {}
+    template_data['tags_http_uri'] = tags_http_uri = {}
+
+
+    for item in blog_items['items']:
+        blog_canonical_url = '/'.join([blog_base_url_item, utils.quote(item['id'])])
+        if len(blog_canonical_url) > URL_LIMIT_MARK:
+            blog_url = blog_canonical_url
+        elif '-' not in item['id']:
+            # we add text from title or body at the end of URL
+            # to make it more human readable
+            # we do it only if there is no "-", as a "-" probably means that
+            # item's id is already user friendly.
+            # TODO: to be removed,  this is only kept for a transition period until
+            #   user friendly item IDs are more common.
+            text = regex.url_friendly_text(item.get('title', item['content']))
+            if text:
+                blog_url = blog_canonical_url + '/' + text
+            else:
+                blog_url = blog_canonical_url
+        else:
+            blog_url = blog_canonical_url
+
+        items_http_uri[item['id']] = self.host.get_ext_base_url(request, blog_url)
+        for tag in item['tags']:
+            if tag not in tags_http_uri:
+                tag_url = '/'.join([blog_base_url_tag, utils.quote(tag)])
+                tags_http_uri[tag] = self.host.get_ext_base_url(request, tag_url)
+
+    # if True, page should display a comment box
+    template_data['allow_commenting'] = data.get('allow_commenting', profile_connected)
+
+    # last but not least, we add a xmpp: link to the node
+    uri_args = {'path': service.full()}
+    if node:
+        uri_args['node'] = node
+    if item_id:
+        uri_args['item'] = item_id
+    template_data['xmpp_uri'] = uri.build_xmpp_uri(
+        'pubsub', subtype='microblog', **uri_args
+    )
+
+
+async def on_data_post(self, request):
+    profile = self.get_profile(request)
+    if profile is None:
+        self.page_error(request, C.HTTP_FORBIDDEN)
+    type_ = self.get_posted_data(request, 'type')
+    if type_ == 'comment':
+        service, node, body = self.get_posted_data(request, ('service', 'node', 'body'))
+
+        if not body:
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+        comment_data = {"content_rich": body}
+        try:
+            await self.host.bridge_call('mb_send',
+                                       service,
+                                       node,
+                                       data_format.serialise(comment_data),
+                                       profile)
+        except Exception as e:
+            if "forbidden" in str(e):
+                self.page_error(request, 401)
+            else:
+                raise e
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/calendar/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,25 @@
+from browser import document, window
+from browser.timer import set_interval
+
+calendar_start = window.calendar_start
+
+
+def update_current_time_line():
+    now = window.Date.new()
+
+    # Calculate the position of the current-time-line
+    now_ts = now.getTime() / 1000
+    minutes_passed = (now_ts - calendar_start) / 60
+
+    new_top = minutes_passed + 15
+
+    # Update the current-time-line position and make it visible
+    current_time_line = document["current-time-line"]
+    current_time_line.style.top = f"{new_top}px"
+    current_time_line.hidden = False
+
+# Initial update
+update_current_time_line()
+
+# Update the current-time-line every minute
+set_interval(update_current_time_line, 60 * 1000)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/calendar/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from twisted.internet import defer
+import datetime
+import time
+from dateutil import tz
+
+from libervia.web.server.constants import Const as C
+
+log = getLogger(__name__)
+
+
+name = "calendar"
+access = C.PAGES_ACCESS_PROFILE
+template = "calendar/daily.html"
+
+
+async def prepare_render(self, request):
+    profile = self.get_profile(request)
+    template_data = request.template_data
+    # template_data["url_event_new"] = self.get_sub_page_url(request, "event_new")
+    if profile is not None:
+        try:
+            events = data_format.deserialise(
+                await self.host.bridge_call("events_get", "", "", [], "", profile),
+                type_check=list
+            )
+        except Exception as e:
+            log.warning(_("Can't get events list for {profile}: {reason}").format(
+                profile=profile, reason=e))
+        else:
+            template_data["events"] = events
+
+    tz_name = template_data["tz_name"] = time.tzname[0]
+    local_tz = tz.tzlocal()
+    today_local = datetime.datetime.now(local_tz).date()
+    calendar_start = template_data["calendar_start"] = datetime.datetime.combine(
+        today_local, datetime.time.min, tzinfo=local_tz
+    ).timestamp()
+    calendar_end = template_data["calendar_end"] = datetime.datetime.combine(
+        today_local, datetime.time.max, tzinfo=local_tz
+    ).timestamp()
+    self.expose_to_scripts(
+        request,
+        calendar_start=calendar_start,
+        calendar_end=calendar_end,
+        tz_name=tz_name,
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/calls/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,513 @@
+import json
+import re
+
+from bridge import AsyncBridge as Bridge
+from browser import aio, console as log, document, timer, window
+import errors
+import loading
+
+log.warning = log.warn
+profile = window.profile or ""
+bridge = Bridge()
+GATHER_TIMEOUT = 10000
+
+
+class WebRTCCall:
+
+    def __init__(self):
+        self.reset_instance()
+
+    def reset_instance(self):
+        """Inits or resets the instance variables to their default state."""
+        self._peer_connection = None
+        self._media_types = None
+        self.sid = None
+        self.local_candidates = None
+        self.remote_stream = None
+        self.candidates_buffer = {
+            "audio": {"candidates": []},
+            "video": {"candidates": []},
+        }
+        self.media_candidates = {}
+        self.candidates_gathered = aio.Future()
+
+    @property
+    def media_types(self):
+        if self._media_types is None:
+            raise Exception("self._media_types should not be None!")
+        return self._media_types
+
+    def get_sdp_mline_index(self, media_type):
+        """Gets the sdpMLineIndex for a given media type.
+
+        @param media_type: The type of the media.
+        """
+        for index, m_type in self.media_types.items():
+            if m_type == media_type:
+                return index
+        raise ValueError(f"Media type '{media_type}' not found")
+
+    def extract_pwd_ufrag(self, sdp):
+        """Retrieves ICE password and user fragment for SDP offer.
+
+        @param sdp: The Session Description Protocol offer string.
+        """
+        ufrag_line = re.search(r"ice-ufrag:(\S+)", sdp)
+        pwd_line = re.search(r"ice-pwd:(\S+)", sdp)
+
+        if ufrag_line and pwd_line:
+            return ufrag_line.group(1), pwd_line.group(1)
+        else:
+            log.error(f"SDP with missing ice-ufrag or ice-pwd:\n{sdp}")
+            raise ValueError("Can't extract ice-ufrag and ice-pwd from SDP")
+
+    def extract_fingerprint_data(self, sdp):
+        """Retrieves fingerprint data from an SDP offer.
+
+        @param sdp: The Session Description Protocol offer string.
+        @return: A dictionary containing the fingerprint data.
+        """
+        fingerprint_line = re.search(r"a=fingerprint:(\S+)\s+(\S+)", sdp)
+        if fingerprint_line:
+            algorithm, fingerprint = fingerprint_line.groups()
+            fingerprint_data = {
+                "hash": algorithm,
+                "fingerprint": fingerprint
+            }
+
+            setup_line = re.search(r"a=setup:(\S+)", sdp)
+            if setup_line:
+                setup = setup_line.group(1)
+                fingerprint_data["setup"] = setup
+
+            return fingerprint_data
+        else:
+            raise ValueError("fingerprint should not be missing")
+
+    def parse_ice_candidate(self, candidate_string):
+        """Parses the ice candidate string.
+
+        @param candidate_string: The ice candidate string to be parsed.
+        """
+        pattern = re.compile(
+            r"candidate:(?P<foundation>\S+) (?P<component_id>\d+) (?P<transport>\S+) "
+            r"(?P<priority>\d+) (?P<address>\S+) (?P<port>\d+) typ "
+            r"(?P<type>\S+)(?: raddr (?P<rel_addr>\S+) rport "
+            r"(?P<rel_port>\d+))?(?: generation (?P<generation>\d+))?"
+        )
+        match = pattern.match(candidate_string)
+        if match:
+            candidate_dict = match.groupdict()
+
+            # Apply the correct types to the dictionary values
+            candidate_dict["component_id"] = int(candidate_dict["component_id"])
+            candidate_dict["priority"] = int(candidate_dict["priority"])
+            candidate_dict["port"] = int(candidate_dict["port"])
+
+            if candidate_dict["rel_port"]:
+                candidate_dict["rel_port"] = int(candidate_dict["rel_port"])
+
+            if candidate_dict["generation"]:
+                candidate_dict["generation"] = candidate_dict["generation"]
+
+            # Remove None values
+            return {k: v for k, v in candidate_dict.items() if v is not None}
+        else:
+            log.warning(f"can't parse candidate: {candidate_string!r}")
+            return None
+
+    def build_ice_candidate(self, parsed_candidate):
+        """Builds ICE candidate
+
+        @param parsed_candidate: Dictionary containing parsed ICE candidate
+        """
+        base_format = (
+            "candidate:{foundation} {component_id} {transport} {priority} "
+            "{address} {port} typ {type}"
+        )
+
+        if ((parsed_candidate.get('rel_addr')
+             and parsed_candidate.get('rel_port'))):
+            base_format += " raddr {rel_addr} rport {rel_port}"
+
+        if parsed_candidate.get('generation'):
+            base_format += " generation {generation}"
+
+        return base_format.format(**parsed_candidate)
+
+    def on_ice_candidate(self, event):
+        """Handles ICE candidate event
+
+        @param event: Event containing the ICE candidate
+        """
+        log.debug(f"on ice candidate {event.candidate=}")
+        if event.candidate and event.candidate.candidate:
+            window.last_event = event
+            parsed_candidate = self.parse_ice_candidate(event.candidate.candidate)
+            if parsed_candidate is None:
+                return
+            try:
+                media_type = self.media_types[event.candidate.sdpMLineIndex]
+            except (TypeError, IndexError):
+                log.error(
+                    f"Can't find media type.\n{event.candidate=}\n{self._media_types=}"
+                )
+                return
+            self.media_candidates.setdefault(media_type, []).append(parsed_candidate)
+            log.debug(f"ICE candidate [{media_type}]: {event.candidate.candidate}")
+        else:
+            log.debug("All ICE candidates gathered")
+
+    def _set_media_types(self, offer):
+        """Sets media types from offer SDP
+
+        @param offer: RTC session description containing the offer
+        """
+        sdp_lines = offer.sdp.splitlines()
+        media_types = {}
+        mline_index = 0
+
+        for line in sdp_lines:
+            if line.startswith("m="):
+                media_types[mline_index] = line[2:line.find(" ")]
+                mline_index += 1
+
+        self._media_types = media_types
+
+    def on_ice_gathering_state_change(self, event):
+        """Handles ICE gathering state change
+
+        @param event: Event containing the ICE gathering state change
+        """
+        connection = event.target
+        log.debug(f"on_ice_gathering_state_change {connection.iceGatheringState=}")
+        if connection.iceGatheringState == "complete":
+            log.info("ICE candidates gathering done")
+            self.candidates_gathered.set_result(None)
+
+    async def _create_peer_connection(
+        self,
+    ):
+        """Creates peer connection"""
+        if self._peer_connection is not None:
+            raise Exception("create_peer_connection can't be called twice!")
+
+        external_disco = json.loads(await bridge.external_disco_get(""))
+        ice_servers = []
+
+        for server in external_disco:
+            ice_server = {}
+            if server["type"] == "stun":
+                ice_server["urls"] = f"stun:{server['host']}:{server['port']}"
+            elif server["type"] == "turn":
+                ice_server["urls"] = (
+                    f"turn:{server['host']}:{server['port']}?transport={server['transport']}"
+                )
+                ice_server["username"] = server["username"]
+                ice_server["credential"] = server["password"]
+            ice_servers.append(ice_server)
+
+        rtc_configuration = {"iceServers": ice_servers}
+
+        peer_connection = window.RTCPeerConnection.new(rtc_configuration)
+        peer_connection.addEventListener("track", self.on_track)
+        peer_connection.addEventListener("negotiationneeded", self.on_negotiation_needed)
+        peer_connection.addEventListener("icecandidate", self.on_ice_candidate)
+        peer_connection.addEventListener("icegatheringstatechange", self.on_ice_gathering_state_change)
+
+        self._peer_connection = peer_connection
+        window.pc = self._peer_connection
+
+    async def _get_user_media(
+        self,
+        audio: bool = True,
+        video: bool = True
+    ):
+        """Gets user media
+
+        @param audio: True if an audio flux is required
+        @param video: True if a video flux is required
+        """
+        media_constraints = {'audio': audio, 'video': video}
+        local_stream = await window.navigator.mediaDevices.getUserMedia(media_constraints)
+        document["local_video"].srcObject = local_stream
+
+        for track in local_stream.getTracks():
+            self._peer_connection.addTrack(track)
+
+    async def _gather_ice_candidates(self, is_initiator: bool, remote_candidates=None):
+        """Get ICE candidates and wait to have them all before returning them
+
+        @param is_initiator: Boolean indicating if the user is the initiator of the connection
+        @param remote_candidates: Remote ICE candidates, if any
+        """
+        if self._peer_connection is None:
+            raise Exception("The peer connection must be created before gathering ICE candidates!")
+
+        self.media_candidates.clear()
+        gather_timeout = timer.set_timeout(
+            lambda: self.candidates_gathered.set_exception(
+                errors.TimeoutError("ICE gathering time out")
+            ),
+            GATHER_TIMEOUT
+        )
+
+        if is_initiator:
+            offer = await self._peer_connection.createOffer()
+            self._set_media_types(offer)
+            await self._peer_connection.setLocalDescription(offer)
+        else:
+            answer = await self._peer_connection.createAnswer()
+            self._set_media_types(answer)
+            await self._peer_connection.setLocalDescription(answer)
+
+        if not is_initiator:
+            log.debug(self._peer_connection.localDescription.sdp)
+        await self.candidates_gathered
+        log.debug(self._peer_connection.localDescription.sdp)
+        timer.clear_timeout(gather_timeout)
+        ufrag, pwd = self.extract_pwd_ufrag(self._peer_connection.localDescription.sdp)
+        return {
+            "ufrag": ufrag,
+            "pwd": pwd,
+            "candidates": self.media_candidates,
+        }
+
+    def on_action_new(
+        self, action_data_s: str, action_id: str, security_limit: int, profile: str
+    ) -> None:
+        """Called when a call is received
+
+        @param action_data_s: Action data serialized
+        @param action_id: Unique identifier for the action
+        @param security_limit: Security limit for the action
+        @param profile: Profile associated with the action
+        """
+        action_data = json.loads(action_data_s)
+        if action_data.get("type") != "call":
+            return
+        peer_jid = action_data["from_jid"]
+        log.info(
+            f"{peer_jid} wants to start a call ({action_data['sub_type']})"
+        )
+        if self.sid is not None:
+            log.warning(
+                f"already in a call ({self.sid}), can't receive a new call from "
+                f"{peer_jid}"
+            )
+            return
+        self.sid = action_data["session_id"]
+        log.debug(f"Call SID: {self.sid}")
+
+        # Answer the call
+        offer_sdp = action_data["sdp"]
+        aio.run(self.answer_call(offer_sdp, action_id))
+
+    def _on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None:
+        """Called when we have received answer SDP from responder
+
+        @param session_id: Session identifier
+        @param sdp: Session Description Protocol data
+        @param profile: Profile associated with the action
+        """
+        aio.run(self.on_call_accepted(session_id, sdp, profile))
+
+    def _on_call_ended(self, session_id: str, data_s: str, profile: str) -> None:
+        """Call has been terminated
+
+        @param session_id: Session identifier
+        @param data_s: Serialised additional data on why the call has ended
+        @param profile: Profile associated
+        """
+        if self.sid is None:
+            log.debug("there are no calls in progress")
+            return
+        if session_id != self.sid:
+            log.debug(
+                f"ignoring call_ended not linked to our call ({self.sid}): {session_id}"
+            )
+            return
+        aio.run(self.end_call())
+
+    async def on_call_accepted(self, session_id: str, sdp: str, profile: str) -> None:
+        """Call has been accepted, connection can be established
+
+        @param session_id: Session identifier
+        @param sdp: Session Description Protocol data
+        @param profile: Profile associated
+        """
+        if self.sid != session_id:
+            log.debug(
+                f"Call ignored due to different session ID ({self.sid=} {session_id=})"
+            )
+            return
+        await self._peer_connection.setRemoteDescription({
+            "type": "answer",
+            "sdp": sdp
+        })
+        await self.on_ice_candidates_new(self.candidates_buffer)
+        self.candidates_buffer.clear()
+
+    def _on_ice_candidates_new(self, sid: str, candidates_s: str, profile: str) -> None:
+        """Called when new ICE candidates are received
+
+        @param sid: Session identifier
+        @param candidates_s: ICE candidates serialized
+        @param profile: Profile associated with the action
+        """
+        if sid != self.sid:
+            log.debug(
+                f"ignoring peer ice candidates for {sid=} ({self.sid=})."
+            )
+            return
+        candidates = json.loads(candidates_s)
+        aio.run(self.on_ice_candidates_new(candidates))
+
+    async def on_ice_candidates_new(self, candidates: dict) -> None:
+        """Called when new ICE canidates are received from peer
+
+        @param candidates: Dictionary containing new ICE candidates
+        """
+        log.debug(f"new peer candidates received: {candidates}")
+        if (
+            self._peer_connection is None
+            or self._peer_connection.remoteDescription is None
+        ):
+            for media_type in ("audio", "video"):
+                media_candidates = candidates.get(media_type)
+                if media_candidates:
+                    buffer = self.candidates_buffer[media_type]
+                    buffer["candidates"].extend(media_candidates["candidates"])
+            return
+        for media_type, ice_data in candidates.items():
+            for candidate in ice_data["candidates"]:
+                candidate_sdp = self.build_ice_candidate(candidate)
+                try:
+                    sdp_mline_index = self.get_sdp_mline_index(media_type)
+                except Exception as e:
+                    log.warning(e)
+                    continue
+                ice_candidate = window.RTCIceCandidate.new({
+                    "candidate": candidate_sdp,
+                    "sdpMLineIndex": sdp_mline_index
+                }
+                                                           )
+                await self._peer_connection.addIceCandidate(ice_candidate)
+
+    def on_track(self, event):
+        """New track has been received from peer
+
+        @param event: Event associated with the new track
+        """
+        if event.streams and event.streams[0]:
+            remote_stream = event.streams[0]
+            document["remote_video"].srcObject = remote_stream
+        else:
+            if self.remote_stream is None:
+                self.remote_stream = window.MediaStream.new()
+                document["remote_video"].srcObject = self.remote_stream
+            self.remote_stream.addTrack(event.track)
+
+        document["call_btn"].classList.add("is-hidden")
+        document["hangup_btn"].classList.remove("is-hidden")
+
+    def on_negotiation_needed(self, event) -> None:
+        log.debug(f"on_negotiation_needed {event=}")
+        # TODO
+
+    async def answer_call(self, offer_sdp: str, action_id: str):
+        """We respond to the call"""
+        log.debug("answering call")
+        await self._create_peer_connection()
+
+        await self._peer_connection.setRemoteDescription({
+            "type": "offer",
+            "sdp": offer_sdp
+        })
+        await self.on_ice_candidates_new(self.candidates_buffer)
+        self.candidates_buffer.clear()
+        await self._get_user_media()
+
+        # Gather local ICE candidates
+        local_ice_data = await self._gather_ice_candidates(False)
+        self.local_candidates = local_ice_data["candidates"]
+
+        await bridge.action_launch(
+            action_id,
+            json.dumps({
+                "sdp": self._peer_connection.localDescription.sdp,
+            })
+        )
+
+    async def make_call(self, audio: bool = True, video: bool = True) -> None:
+        """Start a WebRTC call
+
+        @param audio: True if an audio flux is required
+        @param video: True if a video flux is required
+        """
+        await self._create_peer_connection()
+        await self._get_user_media(audio, video)
+        await self._gather_ice_candidates(True)
+        callee_jid = document["callee_jid"].value
+
+        call_data = {
+            "sdp": self._peer_connection.localDescription.sdp
+        }
+        log.info(f"calling {callee_jid!r}")
+        self.sid = await bridge.call_start(
+            callee_jid,
+            json.dumps(call_data)
+        )
+        log.debug(f"Call SID: {self.sid}")
+
+    async def end_call(self) -> None:
+        """Stop streaming and clean instance"""
+        document["hangup_btn"].classList.add("is-hidden")
+        document["call_btn"].classList.remove("is-hidden")
+        if self._peer_connection is None:
+            log.debug("There is currently no call to end.")
+        else:
+            self._peer_connection.removeEventListener("track", self.on_track)
+            self._peer_connection.removeEventListener("negotiationneeded", self.on_negotiation_needed)
+            self._peer_connection.removeEventListener("icecandidate", self.on_ice_candidate)
+            self._peer_connection.removeEventListener("icegatheringstatechange", self.on_ice_gathering_state_change)
+
+            local_video = document["local_video"]
+            remote_video = document["remote_video"]
+            if local_video.srcObject:
+                for track in local_video.srcObject.getTracks():
+                    track.stop()
+            if remote_video.srcObject:
+                for track in remote_video.srcObject.getTracks():
+                    track.stop()
+
+            self._peer_connection.close()
+            self.reset_instance()
+
+    async def hand_up(self) -> None:
+        """Terminate the call"""
+        session_id = self.sid
+        await self.end_call()
+        await bridge.call_end(
+            session_id,
+            ""
+        )
+
+
+webrtc_call = WebRTCCall()
+
+document["call_btn"].bind(
+    "click",
+    lambda __: aio.run(webrtc_call.make_call())
+)
+document["hangup_btn"].bind(
+    "click",
+    lambda __: aio.run(webrtc_call.hand_up())
+)
+
+bridge.register_signal("action_new", webrtc_call.on_action_new)
+bridge.register_signal("call_accepted", webrtc_call._on_call_accepted)
+bridge.register_signal("call_ended", webrtc_call._on_call_ended)
+bridge.register_signal("ice_candidates_new", webrtc_call._on_ice_candidates_new)
+
+loading.remove_loading_screen()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/calls/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,19 @@
+#!/usr/bin/env python3
+
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from twisted.internet import defer
+import datetime
+import time
+from dateutil import tz
+
+from libervia.web.server.constants import Const as C
+
+log = getLogger(__name__)
+
+
+name = "calls"
+access = C.PAGES_ACCESS_PROFILE
+template = "call/call.html"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/chat/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,122 @@
+#!/usr/bin/env python3
+
+from libervia.backend.core.i18n import _
+from twisted.internet import defer
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_objects
+from libervia.backend.tools.common import data_format
+from twisted.words.protocols.jabber import jid
+from libervia.web.server.constants import Const as C
+from libervia.web.server import session_iface
+
+
+log = getLogger(__name__)
+
+name = "chat"
+access = C.PAGES_ACCESS_PROFILE
+template = "chat/chat.html"
+dynamic = True
+
+
+def parse_url(self, request):
+    rdata = self.get_r_data(request)
+
+    try:
+        target_jid_s = self.next_path(request)
+    except IndexError:
+        # not chat jid, we redirect to jid selection page
+        self.page_redirect("chat_select", request)
+
+    try:
+        target_jid = jid.JID(target_jid_s)
+        if not target_jid.user:
+            raise ValueError(_("invalid jid for chat (no local part)"))
+    except Exception as e:
+        log.warning(
+            _("bad chat jid entered: {jid} ({msg})").format(jid=target_jid, msg=e)
+        )
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    else:
+        rdata["target"] = target_jid
+
+
+@defer.inlineCallbacks
+def prepare_render(self, request):
+    #  FIXME: bug on room filtering (currently display messages from all rooms)
+    session = self.host.get_session_data(request, session_iface.IWebSession)
+    template_data = request.template_data
+    rdata = self.get_r_data(request)
+    target_jid = rdata["target"]
+    profile = session.profile
+    profile_jid = session.jid
+
+    disco = yield self.host.bridge_call("disco_infos", target_jid.host, "", True, profile)
+    if "conference" in [i[0] for i in disco[1]]:
+        chat_type = C.CHAT_GROUP
+        join_ret = yield self.host.bridge_call(
+            "muc_join", target_jid.userhost(), "", "", profile
+        )
+        (already_joined,
+         room_jid_s,
+         occupants,
+         user_nick,
+         room_subject,
+         room_statuses,
+         __) = join_ret
+        template_data["subject"] = room_subject
+        template_data["room_statuses"] = room_statuses
+        own_jid = jid.JID(room_jid_s)
+        own_jid.resource = user_nick
+    else:
+        chat_type = C.CHAT_ONE2ONE
+        own_jid = profile_jid
+    rdata["chat_type"] = chat_type
+    template_data["own_jid"] = own_jid
+
+    self.register_signal(request, "message_new")
+    history = yield self.host.bridge_call(
+        "history_get",
+        profile_jid.userhost(),
+        target_jid.userhost(),
+        20,
+        True,
+        {},
+        profile,
+    )
+    authors = {m[2] for m in history}
+    identities = session.identities
+    for author in authors:
+        id_raw = yield self.host.bridge_call(
+            "identity_get", author, [], True, profile)
+        identities[author] = data_format.deserialise(id_raw)
+
+    template_data["messages"] = data_objects.Messages(history)
+    rdata['identities'] = identities
+    template_data["target_jid"] = target_jid
+    template_data["chat_type"] = chat_type
+
+
+def on_data(self, request, data):
+    session = self.host.get_session_data(request, session_iface.IWebSession)
+    rdata = self.get_r_data(request)
+    target = rdata["target"]
+    data_type = data.get("type", "")
+    if data_type == "msg":
+        message = data["body"]
+        mess_type = (
+            C.MESS_TYPE_GROUPCHAT
+            if rdata["chat_type"] == C.CHAT_GROUP
+            else C.MESS_TYPE_CHAT
+        )
+        log.debug("message received: {}".format(message))
+        self.host.bridge_call(
+            "message_send",
+            target.full(),
+            {"": message},
+            {},
+            mess_type,
+            "",
+            session.profile,
+        )
+    else:
+        log.warning("unknown message type: {type}".format(type=data_type))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/chat/select/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+
+
+from libervia.backend.core.i18n import _
+from libervia.web.server.constants import Const as C
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from libervia.backend.tools.common import data_objects
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+name = "chat_select"
+access = C.PAGES_ACCESS_PROFILE
+template = "chat/select.html"
+
+
+@defer.inlineCallbacks
+def prepare_render(self, request):
+    profile = self.get_profile(request)
+    template_data = request.template_data
+    rooms = template_data["rooms"] = []
+    bookmarks = yield self.host.bridge_call("bookmarks_list", "muc", "all", profile)
+    for bm_values in list(bookmarks.values()):
+        for room_jid, room_data in bm_values.items():
+            url = self.get_page_by_name("chat").get_url(room_jid)
+            rooms.append(data_objects.Room(room_jid, name=room_data.get("name"), url=url))
+    rooms.sort(key=lambda r: r.name)
+
+
+@defer.inlineCallbacks
+def on_data_post(self, request):
+    jid_ = self.get_posted_data(request, "jid")
+    if "@" not in jid_:
+        profile = self.get_profile(request)
+        service = yield self.host.bridge_call("muc_get_service", "", profile)
+        if service:
+            muc_jid = jid.JID(service)
+            muc_jid.user = jid_
+            jid_ = muc_jid.full()
+        else:
+            log.warning(_("Invalid jid received: {jid}".format(jid=jid_)))
+            defer.returnValue(C.POST_NO_CONFIRM)
+    url = self.get_page_by_name("chat").get_url(jid_)
+    self.http_redirect(request, url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/embed/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,26 @@
+#!/usr/bin/env python3
+
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.web.server.constants import Const as C
+
+log = getLogger(__name__)
+
+name = "embed_app"
+template = "embed/embed.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["app_name"], min_args=1)
+    data = self.get_r_data(request)
+    app_name = data["app_name"]
+    try:
+        app_data = self.vhost_root.libervia_apps[app_name]
+    except KeyError:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    template_data = request.template_data
+    template_data['full_screen_body'] = True
+    try:
+        template_data["target_url"] = app_data["url_prefix"]
+    except KeyError:
+        raise exceptions.InternalError(f'"url_prefix" is missing for {app_name!r}')
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/events/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,52 @@
+from browser import DOMNode, document, aio
+from javascript import JSON
+from bridge import AsyncBridge as 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.interest_retract("", 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.ps_node_delete(
+                    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)))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/events/admin/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,173 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from twisted.words.protocols.jabber import jid
+from libervia.backend.tools.common.template import safe
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+import time
+import html
+import math
+import re
+
+name = "event_admin"
+label = D_("Event Administration")
+access = C.PAGES_ACCESS_PROFILE
+template = "event/admin.html"
+log = getLogger(__name__)
+REG_EMAIL_RE = re.compile(C.REG_EMAIL_RE, re.IGNORECASE)
+
+
+def parse_url(self, request):
+    self.get_path_args(
+        request,
+        ("event_service", "event_node", "event_id"),
+        min_args=2,
+        event_service="@jid",
+        event_id="",
+    )
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+
+    ## Event ##
+
+    event_service = template_data["event_service"] = data["event_service"]
+    event_node = template_data["event_node"] = data["event_node"]
+    event_id = template_data["event_id"] = data["event_id"]
+    profile = self.get_profile(request)
+    event_timestamp, event_data = await self.host.bridge_call(
+        "eventGet",
+        event_service.userhost() if event_service else "",
+        event_node,
+        event_id,
+        profile,
+    )
+    try:
+        background_image = event_data.pop("background-image")
+    except KeyError:
+        pass
+    else:
+        template_data["dynamic_style"] = safe(
+            """
+            html {
+                background-image: url("%s");
+                background-size: 15em;
+            }
+            """
+            % html.escape(background_image, True)
+        )
+    template_data["event"] = event_data
+    invitees = await self.host.bridge_call(
+        "event_invitees_list",
+        event_data["invitees_service"],
+        event_data["invitees_node"],
+        profile,
+    )
+    template_data["invitees"] = invitees
+    invitees_guests = 0
+    for invitee_data in invitees.values():
+        if invitee_data.get("attend", "no") == "no":
+            continue
+        try:
+            invitees_guests += int(invitee_data.get("guests", 0))
+        except ValueError:
+            log.warning(
+                _("guests value is not valid: {invitee}").format(invitee=invitee_data)
+            )
+    template_data["invitees_guests"] = invitees_guests
+    template_data["days_left"] = int(
+        math.ceil((event_timestamp - time.time()) / (60 * 60 * 24))
+    )
+
+    ## Blog ##
+
+    data["service"] = jid.JID(event_data["blog_service"])
+    data["node"] = event_data["blog_node"]
+    data["allow_commenting"] = "simple"
+
+    # we now need blog items, using blog common page
+    # this will fill the "items" template data
+    blog_page = self.get_page_by_name("blog_view")
+    await blog_page.prepare_render(self, request)
+
+
+async def on_data_post(self, request):
+    profile = self.get_profile(request)
+    if not profile:
+        log.error("got post data without profile")
+        self.page_error(request, C.HTTP_INTERNAL_ERROR)
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "blog":
+        service, node, title, body, lang = self.get_posted_data(
+            request, ("service", "node", "title", "body", "language")
+        )
+
+        if not body.strip():
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+        data = {"content": body}
+        if title:
+            data["title"] = title
+        if lang:
+            data["language"] = lang
+        try:
+            comments = bool(self.get_posted_data(request, "comments").strip())
+        except KeyError:
+            pass
+        else:
+            if comments:
+                data["allow_comments"] = True
+
+        try:
+            await self.host.bridge_call(
+                "mb_send", service, node, data_format.serialise(data), profile)
+        except Exception as e:
+            if "forbidden" in str(e):
+                self.page_error(request, C.HTTP_FORBIDDEN)
+            else:
+                raise e
+    elif type_ == "event":
+        service, node, event_id, jids, emails = self.get_posted_data(
+            request, ("service", "node", "event_id", "jids", "emails")
+        )
+        for invitee_jid_s in jids.split():
+            try:
+                invitee_jid = jid.JID(invitee_jid_s)
+            except RuntimeError:
+                log.warning(
+                    _("this is not a valid jid: {jid}").format(jid=invitee_jid_s)
+                )
+                continue
+            await self.host.bridge_call(
+                "event_invite", invitee_jid.userhost(), service, node, event_id, profile
+            )
+        for email_addr in emails.split():
+            if not REG_EMAIL_RE.match(email_addr):
+                log.warning(
+                    _("this is not a valid email address: {email}").format(
+                        email=email_addr
+                    )
+                )
+                continue
+            await self.host.bridge_call(
+                "event_invite_by_email",
+                service,
+                node,
+                event_id,
+                email_addr,
+                {},
+                "",
+                "",
+                "",
+                "",
+                "",
+                "",
+                profile,
+            )
+
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/events/new/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,39 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from twisted.internet import defer
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import date_utils
+
+"""creation of new events"""
+
+name = "event_new"
+access = C.PAGES_ACCESS_PROFILE
+template = "event/create.html"
+log = getLogger(__name__)
+
+
+@defer.inlineCallbacks
+def on_data_post(self, request):
+    request_data = self.get_r_data(request)
+    profile = self.get_profile(request)
+    title, location, body, date, main_img, bg_img = self.get_posted_data(
+        request, ("name", "location", "body", "date", "main_image", "bg_image")
+    )
+    timestamp = date_utils.date_parse(date)
+    data = {"name": title, "description": body, "location": location}
+
+    for value, var in ((main_img, "image"), (bg_img, "background-image")):
+        value = value.strip()
+        if not value:
+            continue
+        if not value.startswith("http"):
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+        data[var] = value
+    data["register"] = C.BOOL_TRUE
+    node = yield self.host.bridge_call("event_create", timestamp, data, "", "", "", profile)
+    log.info("Event node created at {node}".format(node=node))
+
+    request_data["post_redirect_page"] = (self.get_page_by_name("event_admin"), "@", node)
+    defer.returnValue(C.POST_NO_CONFIRM)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/events/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from twisted.internet import defer
+
+from libervia.web.server.constants import Const as C
+
+log = getLogger(__name__)
+
+
+name = "events"
+access = C.PAGES_ACCESS_PUBLIC
+template = "event/overview.html"
+
+
+async def prepare_render(self, request):
+    profile = self.get_profile(request)
+    template_data = request.template_data
+    template_data["url_event_new"] = self.get_sub_page_url(request, "event_new")
+    if profile is not None:
+        try:
+            events = data_format.deserialise(
+                await self.host.bridge_call("events_get", "", "", [], "", profile),
+                type_check=list
+            )
+        except Exception as e:
+            log.warning(_("Can't get events list for {profile}: {reason}").format(
+                profile=profile, reason=e))
+        else:
+            own_events = []
+            other_events = []
+            for event in events:
+                if C.bool(event.get("creator", C.BOOL_FALSE)):
+                    own_events.append(event)
+                    event["url"] = self.get_sub_page_url(
+                        request,
+                        "event_admin",
+                    )
+                else:
+                    other_events.append(event)
+                    event["url"] = self.get_sub_page_url(
+                        request,
+                        "event_rsvp",
+                    )
+                if "thumb_url" not in event and "image" in event:
+                    event["thumb_url"] = event["image"]
+
+            template_data["events"] = own_events + other_events
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/events/rsvp/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,101 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _, D_
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common.template import safe
+import time
+import html
+
+"""creation of new events"""
+
+name = "event_rsvp"
+label = D_("Event Invitation")
+access = C.PAGES_ACCESS_PROFILE
+template = "event/invitation.html"
+log = getLogger(__name__)
+
+
+def parse_url(self, request):
+    self.get_path_args(
+        request,
+        ("event_service", "event_node", "event_id"),
+        min_args=2,
+        event_service="@jid",
+        event_id="",
+    )
+
+
+@defer.inlineCallbacks
+def prepare_render(self, request):
+    template_data = request.template_data
+    data = self.get_r_data(request)
+    profile = self.get_profile(request)
+
+    ## Event ##
+
+    event_service = data["event_service"]
+    event_node = data["event_node"]
+    event_id = data["event_id"]
+    event_timestamp, event_data = yield self.host.bridge_call(
+        "eventGet",
+        event_service.userhost() if event_service else "",
+        event_node,
+        event_id,
+        profile,
+    )
+    try:
+        background_image = event_data.pop("background-image")
+    except KeyError:
+        pass
+    else:
+        template_data["dynamic_style"] = safe(
+            """
+            html {
+                background-image: url("%s");
+                background-size: 15em;
+            }
+            """
+            % html.escape(background_image, True)
+        )
+    template_data["event"] = event_data
+    event_invitee_data = yield self.host.bridge_call(
+        "event_invitee_get",
+        event_data["invitees_service"],
+        event_data["invitees_node"],
+        '',
+        profile,
+    )
+    template_data["invitee"] = event_invitee_data
+    template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24))
+
+    ## Blog ##
+
+    data["service"] = jid.JID(event_data["blog_service"])
+    data["node"] = event_data["blog_node"]
+    data["allow_commenting"] = "simple"
+
+    # we now need blog items, using blog common page
+    # this will fill the "items" template data
+    blog_page = self.get_page_by_name("blog_view")
+    yield blog_page.prepare_render(self, request)
+
+
+@defer.inlineCallbacks
+def on_data_post(self, request):
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "comment":
+        blog_page = self.get_page_by_name("blog_view")
+        yield blog_page.on_data_post(self, request)
+    elif type_ == "attendance":
+        profile = self.get_profile(request)
+        service, node, attend, guests = self.get_posted_data(
+            request, ("service", "node", "attend", "guests")
+        )
+        data = {"attend": attend, "guests": guests}
+        yield self.host.bridge_call("event_invitee_set", service, node, data, profile)
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/events/view/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+from libervia.web.server import session_iface
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common.template import safe
+import time
+import html
+from libervia.backend.core.log import getLogger
+
+name = "event_view"
+access = C.PAGES_ACCESS_PROFILE
+template = "event/invitation.html"
+log = getLogger(__name__)
+
+
+@defer.inlineCallbacks
+def prepare_render(self, request):
+    template_data = request.template_data
+    guest_session = self.host.get_session_data(request, session_iface.IWebGuestSession)
+    try:
+        event_uri = guest_session.data["event_uri"]
+    except KeyError:
+        log.warning(_("event URI not found, can't render event page"))
+        self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE)
+
+    data = self.get_r_data(request)
+
+    ## Event ##
+
+    event_uri_data = uri.parse_xmpp_uri(event_uri)
+    if event_uri_data["type"] != "pubsub":
+        self.page_error(request, C.HTTP_SERVICE_UNAVAILABLE)
+
+    event_service = template_data["event_service"] = jid.JID(event_uri_data["path"])
+    event_node = template_data["event_node"] = event_uri_data["node"]
+    event_id = template_data["event_id"] = event_uri_data.get("item", "")
+    profile = self.get_profile(request)
+    event_timestamp, event_data = yield self.host.bridge_call(
+        "eventGet", event_service.userhost(), event_node, event_id, profile
+    )
+    try:
+        background_image = event_data.pop("background-image")
+    except KeyError:
+        pass
+    else:
+        template_data["dynamic_style"] = safe(
+            """
+            html {
+                background-image: url("%s");
+                background-size: 15em;
+            }
+            """
+            % html.escape(background_image, True)
+        )
+    template_data["event"] = event_data
+    event_invitee_data = yield self.host.bridge_call(
+        "event_invitee_get",
+        event_data["invitees_service"],
+        event_data["invitees_node"],
+        '',
+        profile,
+    )
+    template_data["invitee"] = event_invitee_data
+    template_data["days_left"] = int((event_timestamp - time.time()) / (60 * 60 * 24))
+
+    ## Blog ##
+
+    data["service"] = jid.JID(event_data["blog_service"])
+    data["node"] = event_data["blog_node"]
+    data["allow_commenting"] = "simple"
+
+    # we now need blog items, using blog common page
+    # this will fill the "items" template data
+    blog_page = self.get_page_by_name("blog_view")
+    yield blog_page.prepare_render(self, request)
+
+
+@defer.inlineCallbacks
+def on_data_post(self, request):
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "comment":
+        blog_page = self.get_page_by_name("blog_view")
+        yield blog_page.on_data_post(self, request)
+    elif type_ == "attendance":
+        profile = self.get_profile(request)
+        service, node, attend, guests = self.get_posted_data(
+            request, ("service", "node", "attend", "guests")
+        )
+        data = {"attend": attend, "guests": guests}
+        yield self.host.bridge_call("event_invitee_set", service, node, data, profile)
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/files/list/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+
+import json
+import os
+from pathlib import Path
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import uri
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.web.server.constants import Const as C
+from libervia.web.server import session_iface
+from libervia.web.server import pages_tools
+
+log = getLogger(__name__)
+"""files handling pages"""
+
+name = "files_list"
+access = C.PAGES_ACCESS_PROFILE
+template = "file/overview.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="")
+
+
+def add_breadcrumb(self, request, breadcrumbs):
+    data = self.get_r_data(request)
+    breadcrumbs.append({
+        "label": data["service"],
+        "url": self.get_url(data["service"].full()),
+        "icon": "server",
+    })
+    for idx, p in enumerate(data["path"]):
+        breadcrumbs.append({
+            "label": p,
+            "url": self.get_url(data["service"].full(), *data["path"][:idx+1]),
+            "icon": "folder-open-empty",
+        })
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    thumb_limit = data.get("thumb_limit", 400)
+    template_data = request.template_data
+    service, path_elts = data["service"], data["path"]
+    path = Path('/', *path_elts)
+    profile = self.get_profile(request) or C.SERVICE_PROFILE
+    session_data = self.host.get_session_data(
+        request, session_iface.IWebSession
+    )
+
+    try:
+        files_data = await self.host.bridge_call(
+            "fis_list", service.full(), str(path), {}, profile)
+    except BridgeException as e:
+        if e.condition == 'item-not-found':
+            log.debug(
+                f'"item-not-found" received for {path} at {service}, this may indicate '
+                f'that the location is new')
+            files_data = []
+        else:
+            raise e
+    for file_data in files_data:
+        try:
+            extra_raw = file_data["extra"]
+        except KeyError:
+            pass
+        else:
+            file_data["extra"] = json.loads(extra_raw) if extra_raw else {}
+        dir_path = path_elts + [file_data["name"]]
+        if file_data["type"] == C.FILE_TYPE_DIRECTORY:
+            page = self
+        elif file_data["type"] == C.FILE_TYPE_FILE:
+            page = self.get_page_by_name("files_view")
+
+            # we set URL for the last thumbnail which has a size below thumb_limit
+            try:
+                thumbnails = file_data["extra"]["thumbnails"]
+                thumb = thumbnails[0]
+                for thumb_data in thumbnails:
+                    if thumb_data["size"][0] > thumb_limit:
+                        break
+                    thumb = thumb_data
+                file_data["thumb_url"] = (
+                    thumb.get("url")
+                    or os.path.join(session_data.cache_dir, thumb["filename"])
+                )
+            except (KeyError, IndexError):
+                pass
+        else:
+            raise ValueError(
+                "unexpected file type: {file_type}".format(file_type=file_data["type"])
+            )
+        file_data["url"] = page.get_url(service.full(), *dir_path)
+
+        ## comments ##
+        comments_url = file_data.get("comments_url")
+        if comments_url:
+            parsed_url = uri.parse_xmpp_uri(comments_url)
+            comments_service = file_data["comments_service"] = parsed_url["path"]
+            comments_node = file_data["comments_node"] = parsed_url["node"]
+            try:
+                comments_count = file_data["comments_count"] = int(
+                    file_data["comments_count"]
+                )
+            except KeyError:
+                comments_count = None
+            if comments_count and data.get("retrieve_comments", False):
+                file_data["comments"] = await pages_tools.retrieve_comments(
+                    self, comments_service, comments_node, profile=profile
+                )
+
+    # parent dir affiliation
+    # TODO: some caching? What if affiliation changes?
+
+    try:
+        affiliations = await self.host.bridge_call(
+            "fis_affiliations_get", service.full(), "", str(path), profile
+        )
+    except BridgeException as e:
+        if e.condition == 'item-not-found':
+            log.debug(
+                f'"item-not-found" received for {path} at {service}, this may indicate '
+                f'that the location is new')
+            # FIXME: Q&D handling of empty dir (e.g. new directory/photos album)
+            affiliations = {
+                session_data.jid.userhost(): "owner"
+            }
+        if e.condition == "service-unavailable":
+            affiliations = {}
+        else:
+            raise e
+
+    directory_affiliation = affiliations.get(session_data.jid.userhost())
+    if directory_affiliation == "owner":
+        # we need to transtype dict items to str because with some bridges (D-Bus)
+        # we have a specific type which can't be exposed
+        self.expose_to_scripts(
+            request,
+            affiliations={str(e): str(a) for e, a in affiliations.items()}
+        )
+
+    template_data["directory_affiliation"] = directory_affiliation
+    template_data["files_data"] = files_data
+    template_data["path"] = path
+    self.expose_to_scripts(
+        request,
+        directory_affiliation=str(directory_affiliation),
+        files_service=service.full(),
+        files_path=str(path),
+    )
+    if path_elts:
+        template_data["parent_url"] = self.get_url(service.full(), *path_elts[:-1])
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/files/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,52 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+"""files handling pages"""
+
+name = "files"
+access = C.PAGES_ACCESS_PROFILE
+template = "file/discover.html"
+
+
+async def prepare_render(self, request):
+    profile = self.get_profile(request)
+    template_data = request.template_data
+    namespace = self.host.ns_map["fis"]
+    entities_services, entities_own, entities_roster = await self.host.bridge_call(
+        "disco_find_by_features", [namespace], [], False, True, True, True, False, profile
+    )
+    tpl_service_entities = template_data["disco_service_entities"] = {}
+    tpl_own_entities = template_data["disco_own_entities"] = {}
+    tpl_roster_entities = template_data["disco_roster_entities"] = {}
+    entities_url = template_data["entities_url"] = {}
+
+    # we store identities in dict of dict using category and type as keys
+    # this way it's easier to test category in the template
+    for tpl_entities, entities_map in (
+        (tpl_service_entities, entities_services),
+        (tpl_own_entities, entities_own),
+        (tpl_roster_entities, entities_roster),
+    ):
+        for entity_str, entity_ids in entities_map.items():
+            entity_jid = jid.JID(entity_str)
+            tpl_entities[entity_jid] = identities = {}
+            for cat, type_, name in entity_ids:
+                identities.setdefault(cat, {}).setdefault(type_, []).append(name)
+            entities_url[entity_jid] = self.get_page_by_name("files_list").get_url(
+                entity_jid.full()
+            )
+
+
+def on_data_post(self, request):
+    jid_str = self.get_posted_data(request, "jid")
+    try:
+        jid_ = jid.JID(jid_str)
+    except RuntimeError:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    url = self.get_page_by_name("files_list").get_url(jid_.full())
+    self.http_redirect(request, url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/files/view/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,58 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _
+from twisted.web import static
+from libervia.web.server.utils import ProgressHandler
+import tempfile
+import os
+import os.path
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+"""files handling pages"""
+
+name = "files_view"
+access = C.PAGES_ACCESS_PROFILE
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "*path"], min_args=2, service="jid", path="")
+
+
+def cleanup(__, tmp_dir, dest_path):
+    try:
+        os.unlink(dest_path)
+    except OSError:
+        log.warning(_("Can't remove temporary file {path}").format(path=dest_path))
+    try:
+        os.rmdir(tmp_dir)
+    except OSError:
+        log.warning(_("Can't remove temporary directory {path}").format(path=tmp_dir))
+
+
+async def render(self, request):
+    data = self.get_r_data(request)
+    profile = self.get_profile(request)
+    service, path_elts = data["service"], data["path"]
+    basename = path_elts[-1]
+    dir_elts = path_elts[:-1]
+    dir_path = "/".join(dir_elts)
+    tmp_dir = tempfile.mkdtemp()
+    dest_path = os.path.join(tmp_dir, basename)
+    request.notifyFinish().addCallback(cleanup, tmp_dir, dest_path)
+    progress_id = await self.host.bridge_call(
+        "file_jingle_request",
+        service.full(),
+        dest_path,
+        basename,
+        "",
+        "",
+        {"path": dir_path},
+        profile,
+    )
+    log.debug("file requested")
+    await ProgressHandler(self.host, progress_id, profile).register()
+    log.debug("file downloaded")
+    self.delegate_to_resource(request, static.File(dest_path))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/forums/list/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,66 @@
+#!/usr/bin/env python3
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import uri as xmpp_uri
+
+log = getLogger(__name__)
+import json
+
+"""forum handling pages"""
+
+name = "forums"
+access = C.PAGES_ACCESS_PUBLIC
+template = "forum/overview.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(
+        request,
+        ["service", "node", "forum_key"],
+        service="@jid",
+        node="@",
+        forum_key="",
+    )
+
+
+def add_breadcrumb(self, request, breadcrumbs):
+    # we don't want breadcrumbs here as long as there is no forum discovery
+    # because it will be the landing page for forums activity until then
+    pass
+
+
+def get_links(self, forums):
+    for forum in forums:
+        try:
+            uri = forum["uri"]
+        except KeyError:
+            pass
+        else:
+            uri = xmpp_uri.parse_xmpp_uri(uri)
+            service = uri["path"]
+            node = uri["node"]
+            forum["http_url"] = self.get_page_by_name("forum_topics").get_url(service, node)
+        if "sub-forums" in forum:
+            get_links(self, forum["sub-forums"])
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    service, node, key = data["service"], data["node"], data["forum_key"]
+    profile = self.get_profile(request) or C.SERVICE_PROFILE
+
+    try:
+        forums_raw = await self.host.bridge_call(
+            "forums_get", service.full() if service else "", node, key, profile
+        )
+    except Exception as e:
+        log.warning(_("Can't retrieve forums: {msg}").format(msg=e))
+        forums = []
+    else:
+        forums = json.loads(forums_raw)
+    get_links(self, forums)
+
+    template_data["forums"] = forums
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/forums/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,3 @@
+
+def prepare_render(self, request):
+    self.page_redirect("forums", request, skip_parse_url=False)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/forums/topics/new/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,5 @@
+import editor
+
+
+editor.set_form_autosave("forum_topic_edit")
+editor.BlogEditor("forum_topic_edit")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/forums/topics/new/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import D_
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+name = "forum_topic_new"
+label = D_("New Topic")
+access = C.PAGES_ACCESS_PROFILE
+template = "blog/publish.html"
+
+
+async def prepare_render(self, request):
+   template_data = request.template_data
+   template_data.update({
+       "post_form_id": "forum_topic_edit",
+       "publish_title": D_("New Forum Topic"),
+       "title_label": D_("Topic"),
+       "title_required": True,
+       "body_label": D_("Message"),
+       "no_tabs": True,
+   })
+
+
+async def on_data_post(self, request):
+    profile = self.get_profile(request)
+    if profile is None:
+        self.page_error(request, C.HTTP_FORBIDDEN)
+    rdata = self.get_r_data(request)
+    service = rdata["service"].full() if rdata["service"] else ""
+    node = rdata["node"]
+    title, body = self.get_posted_data(request, ("title", "body"))
+    title = title.strip()
+    body = body.strip()
+    if not title or not body:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    topic_data = {"title": title, "content": body}
+    try:
+        await self.host.bridge_call(
+            "forum_topic_create", service, node, topic_data, profile
+        )
+    except Exception as e:
+        if "forbidden" in str(e):
+            self.page_error(request, 401)
+        else:
+            raise e
+
+    rdata["post_redirect_page"] = (self.get_page_by_name("forum_topics"), service, node)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/forums/topics/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import uri as xmpp_uri
+from libervia.backend.tools.common import data_format
+from libervia.web.server import session_iface
+
+log = getLogger(__name__)
+
+name = "forum_topics"
+label = D_("Forum Topics")
+access = C.PAGES_ACCESS_PUBLIC
+template = "forum/view_topics.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "node"], 2, service="jid")
+
+
+def add_breadcrumb(self, request, breadcrumbs):
+    data = self.get_r_data(request)
+    breadcrumbs.append({
+        "label": label,
+        "url": self.get_url(data["service"].full(), data["node"])
+    })
+
+
+async def prepare_render(self, request):
+    profile = self.get_profile(request) or C.SERVICE_PROFILE
+    data = self.get_r_data(request)
+    service, node = data["service"], data["node"]
+    request.template_data.update({"service": service, "node": node})
+    template_data = request.template_data
+    page_max = data.get("page_max", 20)
+    extra = self.get_pubsub_extra(request, page_max=page_max)
+    topics, metadata = await self.host.bridge_call(
+        "forum_topics_get",
+        service.full(),
+        node,
+        extra,
+        profile
+    )
+    metadata = data_format.deserialise(metadata)
+    self.set_pagination(request, metadata)
+    identities = self.host.get_session_data(
+        request, session_iface.IWebSession
+    ).identities
+    for topic in topics:
+        parsed_uri = xmpp_uri.parse_xmpp_uri(topic["uri"])
+        author = topic["author"]
+        topic["http_uri"] = self.get_page_by_name("forum_view").get_url(
+            parsed_uri["path"], parsed_uri["node"]
+        )
+        if author not in identities:
+            id_raw = await self.host.bridge_call(
+                "identity_get", author, [], True, profile
+            )
+            identities[topic["author"]] = data_format.deserialise(id_raw)
+
+    template_data["topics"] = topics
+    template_data["url_topic_new"] = self.get_sub_page_url(request, "forum_topic_new")
+
+
+async def on_data_post(self, request):
+    profile = self.get_profile(request)
+    if profile is None:
+        self.page_error(request, C.HTTP_FORBIDDEN)
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "new_topic":
+        service, node, title, body = self.get_posted_data(
+            request, ("service", "node", "title", "body")
+        )
+
+        if not title or not body:
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+        topic_data = {"title": title, "content": body}
+        try:
+            await self.host.bridge_call(
+                "forum_topic_create", service, node, topic_data, profile
+            )
+        except Exception as e:
+            if "forbidden" in str(e):
+                self.page_error(request, 401)
+            else:
+                raise e
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/forums/view/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,51 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+name = "forum_view"
+label = D_("View")
+access = C.PAGES_ACCESS_PUBLIC
+template = "forum/view.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "node"], 2, service="jid")
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    data["show_comments"] = False
+    blog_page = self.get_page_by_name("blog_view")
+    request.args[b"before"] = [b""]
+    request.args[b"reverse"] = [b"1"]
+    await blog_page.prepare_render(self, request)
+    request.template_data["login_url"] = self.get_page_redirect_url(request)
+
+
+async def on_data_post(self, request):
+    profile = self.get_profile(request)
+    if profile is None:
+        self.page_error(request, C.HTTP_FORBIDDEN)
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "comment":
+        service, node, body = self.get_posted_data(request, ("service", "node", "body"))
+
+        if not body:
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+        mb_data = {"content_rich": body}
+        try:
+            await self.host.bridge_call(
+                "mb_send", service, node, data_format.serialise(mb_data), profile)
+        except Exception as e:
+            if "forbidden" in str(e):
+                self.page_error(request, 401)
+            else:
+                raise e
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/g/e/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,4 @@
+#!/usr/bin/env python3
+
+
+redirect = "event_view"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/g/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,172 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.web.server import session_iface
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+access = C.PAGES_ACCESS_PUBLIC
+template = "invitation/welcome.html"
+
+
+async def parse_url(self, request):
+    """check invitation id in URL and start session if needed
+
+    if a session already exists for an other guest/profile, it will be purged
+    """
+    try:
+        invitation_id = self.next_path(request)
+    except IndexError:
+        self.page_error(request)
+
+    web_session, guest_session = self.host.get_session_data(
+        request, session_iface.IWebSession, session_iface.IWebGuestSession
+    )
+    current_id = guest_session.id
+
+    if current_id is not None and current_id != invitation_id:
+        log.info(
+            _(
+                "killing guest session [{old_id}] because it is connecting with an other ID [{new_id}]"
+            ).format(old_id=current_id, new_id=invitation_id)
+        )
+        self.host.purge_session(request)
+        web_session, guest_session = self.host.get_session_data(
+            request, session_iface.IWebSession, session_iface.IWebGuestSession
+        )
+        current_id = None  # FIXME: id not reset here
+        profile = None
+
+    profile = web_session.profile
+    if profile is not None and current_id is None:
+        log.info(
+            _(
+                "killing current profile session [{profile}] because a guest id is used"
+            ).format(profile=profile)
+        )
+        self.host.purge_session(request)
+        web_session, guest_session = self.host.get_session_data(
+            request, session_iface.IWebSession, session_iface.IWebGuestSession
+        )
+        profile = None
+
+    if current_id is None:
+        log.debug(_("checking invitation [{id}]").format(id=invitation_id))
+        try:
+            data = await self.host.bridge_call("invitation_get", invitation_id)
+        except Exception:
+            self.page_error(request, C.HTTP_FORBIDDEN)
+        else:
+            guest_session.id = invitation_id
+            guest_session.data = data
+    else:
+        data = guest_session.data
+
+    if profile is None:
+        log.debug(_("connecting profile [{}]").format(profile))
+        # we need to connect the profile
+        profile = data["guest_profile"]
+        password = data["password"]
+        try:
+            await self.host.connect(request, profile, password)
+        except Exception as e:
+            log.warning(_("Can't connect profile: {msg}").format(msg=e))
+            # FIXME: no good error code correspond
+            #        maybe use a custom one?
+            self.page_error(request, code=C.HTTP_SERVICE_UNAVAILABLE)
+
+        log.info(
+            _(
+                "guest session started, connected with profile [{profile}]".format(
+                    profile=profile
+                )
+            )
+        )
+
+    # we copy data useful in templates
+    template_data = request.template_data
+    template_data["norobots"] = True
+    if "name" in data:
+        template_data["name"] = data["name"]
+    if "language" in data:
+        template_data["locale"] = data["language"]
+
+def handle_event_interest(self, interest):
+    if C.bool(interest.get("creator", C.BOOL_FALSE)):
+        page_name = "event_admin"
+    else:
+        page_name = "event_rsvp"
+
+    interest["url"] = self.get_page_by_name(page_name).get_url(
+        interest.get("service", ""),
+        interest.get("node", ""),
+        interest.get("item"),
+        )
+
+    if "thumb_url" not in interest and "image" in interest:
+        interest["thumb_url"] = interest["image"]
+
+def handle_fis_interest(self, interest):
+    path = interest.get('path', '')
+    path_args = [p for p in path.split('/') if p]
+    subtype = interest.get('subtype')
+
+    if subtype == 'files':
+        page_name = "files_view"
+    elif interest.get('subtype') == 'photos':
+        page_name = "photos_album"
+    else:
+        log.warning("unknown interest subtype: {subtype}".format(subtype=subtype))
+        return False
+
+    interest["url"] = self.get_page_by_name(page_name).get_url(
+        interest['service'], *path_args)
+
+async def prepare_render(self, request):
+    template_data = request.template_data
+    profile = self.get_profile(request)
+
+    # interests
+    template_data['interests_map'] = interests_map = {}
+    try:
+        interests = await self.host.bridge_call(
+            "interests_list", "", "", "", profile)
+    except Exception:
+        log.warning(_("Can't get interests list for {profile}").format(
+            profile=profile))
+    else:
+        # we only want known interests (photos and events for now)
+        # this dict map namespaces of interest to a callback which can manipulate
+        # the data. If it returns False, the interest is skipped
+        ns_data = {}
+
+        for short_name, cb in (('event', handle_event_interest),
+                               ('fis', handle_fis_interest),
+                              ):
+            try:
+                namespace = self.host.ns_map[short_name]
+            except KeyError:
+                pass
+            else:
+                ns_data[namespace] = (cb, short_name)
+
+        for interest in interests:
+            namespace = interest.get('namespace')
+            if namespace not in ns_data:
+                continue
+            cb, short_name = ns_data[namespace]
+            if cb(self, interest) == False:
+                continue
+            key = interest.get('subtype', short_name)
+            interests_map.setdefault(key, []).append(interest)
+
+    # main URI
+    guest_session = self.host.get_session_data(request, session_iface.IWebGuestSession)
+    main_uri = guest_session.data.get("event_uri")
+    if main_uri:
+        include_url = self.get_page_path_from_uri(main_uri)
+        if include_url is not None:
+            template_data["include_url"] = include_url
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,52 @@
+from browser import DOMNode, document, aio
+from javascript import JSON
+from bridge import AsyncBridge as 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"List {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.interest_retract("", item['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.ps_node_delete(
+                    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)))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/create/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+name = "list_create"
+access = C.PAGES_ACCESS_PROFILE
+template = "list/create.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["template_id"])
+    data = self.get_r_data(request)
+    if data["template_id"]:
+        self.http_redirect(
+            request,
+            self.get_page_by_name("list_create_from_tpl").get_url(data["template_id"])
+        )
+
+
+async def prepare_render(self, request):
+    template_data = request.template_data
+    profile = self.get_profile(request)
+    tpl_raw = await self.host.bridge_call(
+        "list_templates_names_get",
+        "",
+        profile,
+    )
+    lists_templates = data_format.deserialise(tpl_raw, type_check=list)
+    template_data["icons_names"] = {tpl['icon'] for tpl in lists_templates}
+    template_data["lists_templates"] = [
+        {
+            "icon": tpl["icon"],
+            "name": tpl["name"],
+            "url": self.get_url(tpl["id"]),
+        }
+        for tpl in lists_templates
+    ]
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/create_from_tpl/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,64 @@
+#!/usr/bin/env python3
+
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import D_
+from libervia.backend.core import exceptions
+from libervia.web.server.constants import Const as C
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+
+log = getLogger(__name__)
+
+name = "list_create_from_tpl"
+access = C.PAGES_ACCESS_PROFILE
+template = "list/create_from_template.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["template_id"])
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_id = data["template_id"]
+    if not template_id:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    template_data = request.template_data
+    profile = self.get_profile(request)
+    tpl_raw = await self.host.bridge_call(
+        "list_template_get",
+        template_id,
+        "",
+        profile,
+    )
+    template = data_format.deserialise(tpl_raw)
+    template['id'] = template_id
+    template_data["list_template"] = template
+
+async def on_data_post(self, request):
+    data = self.get_r_data(request)
+    template_id = data['template_id']
+    name, access = self.get_posted_data(request, ('name', 'access'))
+    if access == 'private':
+        access_model = 'whitelist'
+    elif access == 'public':
+        access_model = 'open'
+    else:
+        log.warning(f"Unknown access for template creation: {access}")
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    profile = self.get_profile(request)
+    try:
+        service, node = await self.host.bridge_call(
+            "list_template_create", template_id, name, access_model, profile
+        )
+    except BridgeException as e:
+        if e.condition == "conflict":
+            raise exceptions.DataError(D_("A list with this name already exists"))
+        else:
+            log.error(f"Can't create list from template: {e}")
+            raise e
+    data["post_redirect_page"] = (
+        self.get_page_by_name("lists"),
+        service,
+        node or "@",
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/edit/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _
+from twisted.internet import defer
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+name = "list_edit"
+access = C.PAGES_ACCESS_PROFILE
+template = "list/edit.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@")
+    data = self.get_r_data(request)
+    if data["item_id"] is None:
+        log.warning(_("no list item id specified"))
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+
+@defer.inlineCallbacks
+def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    service, node, item_id = (
+        data.get("service", ""),
+        data.get("node", ""),
+        data["item_id"],
+    )
+    profile = self.get_profile(request)
+
+    # we don't ignore "author" below to keep it when a list item is edited
+    # by node owner/admin and "consistent publisher" is activated
+    ignore = (
+        "publisher",
+        "author",
+        "author_jid",
+        "author_email",
+        "created",
+        "updated",
+        "comments_uri",
+    )
+    list_raw = yield self.host.bridge_call(
+        "list_get",
+        service.full() if service else "",
+        node,
+        C.NO_LIMIT,
+        [item_id],
+        "",
+        data_format.serialise({}),
+        profile,
+    )
+    list_items, metadata = data_format.deserialise(list_raw, type_check=list)
+    list_item = [template_xmlui.create(self.host, x, ignore=ignore) for x in list_items][0]
+
+    try:
+        # small trick to get a one line text input instead of the big textarea
+        list_item.widgets["labels"].type = "string"
+        list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace(
+            "\n", ", "
+        )
+    except KeyError:
+        pass
+
+    # for now we don't have XHTML editor, so we'll go with a TextBox and a convertion
+    # to a text friendly syntax using markdown
+    wid = list_item.widgets['body']
+    if wid.type == "xhtmlbox":
+        wid.type = "textbox"
+        wid.value =  yield self.host.bridge_call(
+            "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown",
+            False, profile)
+
+    template_data["new_list_item_xmlui"] = list_item
+
+
+async def on_data_post(self, request):
+    data = self.get_r_data(request)
+    service = data["service"]
+    node = data["node"]
+    item_id = data["item_id"]
+    posted_data = self.get_all_posted_data(request)
+    if not posted_data["title"] or not posted_data["body"]:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    try:
+        posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")]
+    except (KeyError, IndexError):
+        pass
+    profile = self.get_profile(request)
+
+    # we convert back body to XHTML
+    body = await self.host.bridge_call(
+        "syntax_convert", 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}
+    await self.host.bridge_call(
+        "list_set", service.full(), node, posted_data, "", item_id,
+        data_format.serialise(extra), profile
+    )
+    data["post_redirect_page"] = (
+        self.get_page_by_name("list_view"),
+        service.full(),
+        node or "@",
+        item_id
+    )
+    return C.POST_NO_CONFIRM
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/new/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,89 @@
+#!/usr/bin/env python3
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+name = "list_new"
+access = C.PAGES_ACCESS_PROFILE
+template = "list/create_item.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "node"], service="jid", node="@")
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    service, node = data.get("service", ""), data.get("node", "")
+    profile = self.get_profile(request)
+    schema = await self.host.bridge_call("list_schema_get", service.full(), node, profile)
+    data["schema"] = schema
+    # following fields are handled in backend
+    ignore = (
+        "author",
+        "author_jid",
+        "author_email",
+        "created",
+        "updated",
+        "comments_uri",
+        "status",
+        "milestone",
+        "priority",
+    )
+    xmlui_obj = template_xmlui.create(self.host, schema, ignore=ignore)
+    try:
+        # small trick to get a one line text input instead of the big textarea
+        xmlui_obj.widgets["labels"].type = "string"
+    except KeyError:
+        pass
+
+    try:
+        wid = xmlui_obj.widgets['body']
+    except KeyError:
+        pass
+    else:
+        if wid.type == "xhtmlbox":
+            # same as for list_edit, we have to convert for now
+            wid.type = "textbox"
+            wid.value =  await self.host.bridge_call(
+                "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown",
+                False, profile)
+    template_data["new_list_item_xmlui"] = xmlui_obj
+
+
+async def on_data_post(self, request):
+    data = self.get_r_data(request)
+    service = data["service"]
+    node = data["node"]
+    posted_data = self.get_all_posted_data(request)
+    if (("title" in posted_data and not posted_data["title"])
+         or ("body" in posted_data and not posted_data["body"])):
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    try:
+        posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")]
+    except (KeyError, IndexError):
+        pass
+    profile = self.get_profile(request)
+
+    # we convert back body to XHTML
+    if "body" in posted_data:
+        body = await self.host.bridge_call(
+            "syntax_convert", 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)]
+
+
+    await self.host.bridge_call(
+        "list_set", service.full(), node, posted_data, "", "", "", profile
+    )
+    # we don't want to redirect to creation page on success, but to list overview
+    data["post_redirect_page"] = (
+        self.get_page_by_name("lists"),
+        service.full(),
+        node or "@",
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,71 @@
+#!/usr/bin/env python3
+
+from libervia.web.server.constants import Const as C
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+
+log = getLogger(__name__)
+
+name = "lists_disco"
+label = D_("Lists Discovery")
+access = C.PAGES_ACCESS_PUBLIC
+template = "list/discover.html"
+
+async def prepare_render(self, request):
+    profile = self.get_profile(request)
+    template_data = request.template_data
+    template_data["url_list_create"] = self.get_page_by_name("list_create").url
+    lists_directory_config = self.host.options["lists_directory_json"]
+    lists_directory = request.template_data["lists_directory"] = []
+
+    if lists_directory_config:
+        try:
+            for list_data in lists_directory_config:
+                service = list_data["service"]
+                node = list_data["node"]
+                name = list_data["name"]
+                url = self.get_page_by_name("lists").get_url(service, node)
+                lists_directory.append({"name": name, "url": url, "from_config": True})
+        except KeyError as e:
+            log.warning("Missing field in lists_directory_json: {msg}".format(msg=e))
+        except Exception as e:
+            log.warning("Can't decode lists directory: {msg}".format(msg=e))
+
+    if profile is not None:
+        try:
+            lists_list_raw = await self.host.bridge_call("lists_list", "", "", profile)
+        except Exception as e:
+            log.warning(
+                _("Can't get list of registered lists for {profile}: {reason}")
+                .format(profile=profile, reason=e)
+            )
+        else:
+            lists_list = data_format.deserialise(lists_list_raw, type_check=list)
+            for list_data in lists_list:
+                service = list_data["service"]
+                node = list_data["node"]
+                list_data["url"] = self.get_page_by_name("lists").get_url(service, node)
+                list_data["from_config"] = False
+                lists_directory.append(list_data)
+
+    icons_names = set()
+    for list_data in lists_directory:
+        try:
+            icons_names.add(list_data['icon_name'])
+        except KeyError:
+            pass
+    if icons_names:
+        template_data["icons_names"] = icons_names
+
+
+def on_data_post(self, request):
+    jid_str = self.get_posted_data(request, "jid")
+    try:
+        jid_ = jid.JID(jid_str)
+    except RuntimeError:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    # for now we just use default node
+    url = self.get_page_by_name("lists").get_url(jid_.full(), "@")
+    self.http_redirect(request, url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/view/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,133 @@
+from browser import window, document, aio, bind
+from invitation import InvitationManager
+from javascript import JSON
+from bridge import Async as Bridge, BridgeException
+import dialog
+
+
+bridge = Bridge()
+lists_ns = window.lists_ns
+pubsub_service = window.pubsub_service
+pubsub_node = window.pubsub_node
+list_type = window.list_type
+try:
+    affiliations = window.affiliations.to_dict()
+except AttributeError:
+    pass
+
+@bind("#button_manage", "click")
+def manage_click(evt):
+    evt.stopPropagation()
+    evt.preventDefault()
+    pubsub_data = {
+        "namespace": lists_ns,
+        "service": pubsub_service,
+        "node": pubsub_node
+    }
+    try:
+        name = pubsub_node.split('_', 1)[1]
+    except IndexError:
+        pass
+    else:
+        name = name.strip()
+        if name:
+            pubsub_data['name'] = name
+    manager = InvitationManager("pubsub", pubsub_data)
+    manager.attach(affiliations=affiliations)
+
+
+async def on_delete(evt):
+    item_elt = evt.target.closest(".item")
+    if item_elt is None:
+        dialog.notification.show(
+            "Can't find parent item element",
+            level="error"
+        )
+        return
+    item_elt.classList.add("selected_for_deletion")
+    item = JSON.parse(item_elt.dataset.item)
+    confirmed = await dialog.Confirm(
+        f"{item['name']!r} will be deleted, are you sure?",
+        ok_label="delete",
+        ok_color="danger",
+    ).ashow()
+    item_elt.classList.remove("selected_for_deletion")
+    if confirmed:
+        try:
+            await bridge.ps_item_retract(pubsub_service, pubsub_node, item["id"], True)
+        except Exception as e:
+            dialog.notification.show(
+                f"Can't delete list item: {e}",
+                level="error"
+            )
+        else:
+            dialog.notification.show("list item deleted successfuly")
+            item_elt.remove()
+
+
+async def on_next_state(evt):
+    """Update item with next state
+
+    Only used with grocery list at the moment
+    """
+    evt.stopPropagation()
+    evt.preventDefault()
+    # FIXME: states are currently hardcoded, it would be better to use schema
+    item_elt = evt.target.closest(".item")
+    if item_elt is None:
+        dialog.notification.show(
+            "Can't find parent item element",
+            level="error"
+        )
+        return
+    item = JSON.parse(item_elt.dataset.item)
+    try:
+        status = item["status"]
+    except (KeyError, IndexError) as e:
+        dialog.notification.show(
+            f"Can't get item status: {e}",
+            level="error"
+        )
+        status = "to_buy"
+    if status == "to_buy":
+        item["status"] = "bought"
+        class_update_method = item_elt.classList.add
+        checked = True
+    elif status == "bought":
+        item["status"] = "to_buy"
+        checked = False
+        class_update_method = item_elt.classList.remove
+    else:
+        dialog.notification.show(
+            f"unexpected item status: {status!r}",
+            level="error"
+        )
+        return
+    item_elt.dataset.item = JSON.stringify(item)
+    try:
+        await bridge.list_set(
+            pubsub_service,
+            pubsub_node,
+            # FIXME: value type should be consistent, or we should serialise
+            {k: (v if isinstance(v, list) else [v]) for k,v in item.items()},
+            "",
+            item["id"],
+            ""
+        )
+    except BridgeException as e:
+        dialog.notification.show(
+            f"Can't udate list item: {e.message}",
+            level="error"
+        )
+    else:
+        evt.target.checked = checked
+        class_update_method("list-item-closed")
+
+
+if list_type == "grocery":
+    for elt in document.select('.click_to_delete'):
+        elt.bind("click", lambda evt: aio.run(on_delete(evt)))
+
+    for elt in document.select('.click_to_next_state'):
+        elt.bind("click", lambda evt: aio.run(on_next_state(evt)))
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/view/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,152 @@
+#!/usr/bin/env python3
+
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.tools.common import data_objects
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.web.server.constants import Const as C
+
+log = getLogger(__name__)
+
+name = "lists"
+access = C.PAGES_ACCESS_PUBLIC
+template = "list/overview.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "node"], service="jid")
+    data = self.get_r_data(request)
+    service, node = data["service"], data["node"]
+    if node is None:
+        self.http_redirect(request, self.get_page_by_name("lists_disco").url)
+    if node == "@":
+        node = data["node"] = ""
+    template_data = request.template_data
+    template_data["url_list_items"] = self.get_url(service.full(), node or "@")
+    template_data["url_list_new"] = self.get_page_by_name("list_new").get_url(
+        service.full(), node or "@")
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    service, node = data["service"], data["node"]
+    submitted_node = await self.host.bridge_call(
+        "ps_schema_submitted_node_get",
+        node or self.host.ns_map["tickets"]
+    )
+    profile = self.get_profile(request) or C.SERVICE_PROFILE
+
+    self.check_cache(request, C.CACHE_PUBSUB, service=service, node=node, short="tickets")
+
+    try:
+        lists_types = self.get_page_data(request, "lists_types")
+        if lists_types is None:
+            lists_types = {}
+            self.set_page_data(request, "lists_types", lists_types)
+        list_type = lists_types[(service, node)]
+    except KeyError:
+        ns_tickets_type = self.host.ns_map["tickets_type"]
+        schema_raw = await self.host.bridge_call(
+            "ps_schema_dict_get",
+            service.full(),
+            submitted_node,
+            profile
+        )
+        schema = data_format.deserialise(schema_raw)
+        try:
+            list_type_field = next(
+                f for f in schema["fields"] if f["type"] == "hidden"
+                and f.get("name") == ns_tickets_type
+            )
+        except StopIteration:
+            list_type = lists_types[(service, node)] = None
+        else:
+            if list_type_field.get("value") is None:
+                list_type = None
+            else:
+                list_type = list_type_field["value"].lower().strip()
+            lists_types[(service, node)] = list_type
+
+    data["list_type"] = template_data["list_type"] = list_type
+
+    extra = self.get_pubsub_extra(request)
+    extra["labels_as_list"] = C.BOOL_TRUE
+    self.handle_search(request, extra)
+
+    list_raw = await self.host.bridge_call(
+        "list_get",
+        service.full() if service else "",
+        node,
+        C.NO_LIMIT,
+        [],
+        "",
+        data_format.serialise(extra),
+        profile,
+    )
+    if profile != C.SERVICE_PROFILE:
+        try:
+            affiliations = await self.host.bridge_call(
+                "ps_node_affiliations_get",
+                service.full() if service else "",
+                submitted_node,
+                profile
+            )
+        except BridgeException as 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.get_jid(request).userhost()) == 'owner'
+            template_data["owner"] = is_owner
+            if is_owner:
+                self.expose_to_scripts(
+                    request,
+                    affiliations={str(e): str(a) for e, a in affiliations.items()}
+                )
+    else:
+        template_data["owner"] = False
+
+    list_items, metadata = data_format.deserialise(list_raw, type_check=list)
+    template_data["list_items"] = [
+        template_xmlui.create(self.host, x) for x in list_items
+    ]
+    view_url = self.get_page_by_name('list_view').get_url(service.full(), node or '@')
+    template_data["on_list_item_click"] = data_objects.OnClick(
+        url=f"{view_url}/{{item.id}}"
+    )
+    self.set_pagination(request, metadata)
+    self.expose_to_scripts(
+        request,
+        lists_ns=self.host.ns_map["tickets"],
+        pubsub_service=service.full(),
+        pubsub_node=node,
+        list_type=list_type,
+    )
+
+
+async def on_data_post(self, request):
+    data = self.get_r_data(request)
+    profile = self.get_profile(request)
+    service = data["service"]
+    node = data["node"]
+    list_type = self.get_posted_data(request, ("type",))
+    if list_type == "grocery":
+        name, quantity = self.get_posted_data(request, ("name", "quantity"))
+        if not name:
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+        item_data = {
+            "name": [name],
+        }
+        if quantity:
+            item_data["quantity"] = [quantity]
+        await self.host.bridge_call(
+            "list_set", service.full(), node, item_data, "", "", "", profile
+        )
+        return C.POST_NO_CONFIRM
+    else:
+        raise NotImplementedError(
+            f"Can't use quick list item set for list of type {list_type!r}"
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/view_item/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,57 @@
+from browser import document, window, aio
+from bridge import AsyncBridge as Bridge
+import dialog
+
+try:
+    pubsub_service = window.pubsub_service
+    pubsub_node = window.pubsub_node
+    pubsub_item = window.pubsub_item
+except AttributeError:
+    can_delete = False
+else:
+    bridge = Bridge()
+    can_delete = True
+
+
+async def on_delete(evt):
+    evt.stopPropagation()
+    confirmed = await dialog.Confirm(
+        "This item will be deleted, are you sure?",
+        ok_label="delete",
+        ok_color="danger",
+    ).ashow()
+    if confirmed:
+        try:
+            comments_service = window.comments_service
+            comments_node = window.comments_node
+        except AttributeError:
+            pass
+        else:
+            print(f"deleting comment node at [{comments_service}] {comments_node!r}")
+            try:
+                await bridge.ps_node_delete(comments_service, comments_node)
+            except Exception as e:
+                dialog.notification.show(
+                    f"Can't delete comment node: {e}",
+                    level="error"
+                )
+
+        print(f"deleting list item {pubsub_item!r} at [{pubsub_service}] {pubsub_node!r}")
+        try:
+            await bridge.ps_item_retract(pubsub_service, pubsub_node, pubsub_item, True)
+        except Exception as e:
+            dialog.notification.show(
+                f"Can't delete list item: {e}",
+                level="error"
+            )
+        else:
+            # FIXME: Q&D way to get list view URL, need to have a proper method (would
+            #   be nice to have a way to reference pages by name from browser)
+            list_url = '/'.join(window.location.pathname.split('/')[:-1]).replace(
+                'view_item', 'view')
+            window.location.replace(list_url)
+
+
+if can_delete:
+    for elt in document.select('.action_delete'):
+        elt.bind("click", lambda evt: aio.run(on_delete(evt)))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/lists/view_item/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,140 @@
+#!/usr/bin/env python3
+
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.i18n import _, D_
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.web.server.constants import Const as C
+from libervia.web.server.utils import SubPage
+from libervia.web.server import session_iface
+
+log = getLogger(__name__)
+
+
+name = "list_view"
+access = C.PAGES_ACCESS_PUBLIC
+template = "list/item.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "node", "item_id"], service="jid", node="@")
+    data = self.get_r_data(request)
+    if data["item_id"] is None:
+        log.warning(_("no list item id specified"))
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+
+
+def add_breadcrumb(self, request, breadcrumbs):
+    data = self.get_r_data(request)
+    list_url = self.get_page_by_name("lists").get_url(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.get_r_data(request)
+    template_data = request.template_data
+    session = self.host.get_session_data(request, session_iface.IWebSession)
+    service, node, item_id = (
+        data.get("service", ""),
+        data.get("node", ""),
+        data["item_id"],
+    )
+    namespace = node or self.host.ns_map["tickets"]
+    node = await self.host.bridge_call("ps_schema_submitted_node_get", namespace)
+
+    profile = self.get_profile(request)
+    if profile is None:
+        profile = C.SERVICE_PROFILE
+
+    list_raw = await self.host.bridge_call(
+        "list_get",
+        service.full() if service else "",
+        node,
+        C.NO_LIMIT,
+        [item_id],
+        "",
+        data_format.serialise({"labels_as_list": C.BOOL_TRUE}),
+        profile,
+    )
+    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:
+        comments_uri = list_item.widgets["comments_uri"].value
+    except KeyError:
+        pass
+    else:
+        if comments_uri:
+            uri_data = uri.parse_xmpp_uri(comments_uri)
+            template_data["comments_node"] = comments_node = uri_data["node"]
+            template_data["comments_service"] = comments_service = uri_data["path"]
+            try:
+                comments = data_format.deserialise(await self.host.bridge_call(
+                    "mb_get", comments_service, comments_node, C.NO_LIMIT, [],
+                    data_format.serialise({}), profile
+                ))
+            except BridgeException as e:
+                if e.classname == 'NotFound' or e.condition == 'item-not-found':
+                    log.warning(
+                        _("Can't find comment node at [{service}] {node!r}")
+                        .format(service=comments_service, node=comments_node)
+                    )
+                else:
+                    raise e
+            else:
+                template_data["comments"] = comments
+                template_data["login_url"] = self.get_page_redirect_url(request)
+                self.expose_to_scripts(
+                    request,
+                    comments_node=comments_node,
+                    comments_service=comments_service,
+                )
+
+    if session.connected:
+        # we activate modification action (edit, delete) only if user is the publisher or
+        # the node owner
+        publisher = jid.JID(list_item.widgets["publisher"].value)
+        is_publisher = publisher.userhostJID() == session.jid.userhostJID()
+        affiliation = None
+        if not is_publisher:
+            affiliation = await self.host.get_affiliation(request, service, node)
+        if is_publisher or affiliation == "owner":
+            self.expose_to_scripts(
+                request,
+                pubsub_service = service.full(),
+                pubsub_node = node,
+                pubsub_item = item_id,
+            )
+            template_data["can_modify"] = True
+            template_data["url_list_item_edit"] = self.get_url_by_path(
+                SubPage("list_edit"),
+                service.full(),
+                node or "@",
+                item_id,
+            )
+
+    # we add xmpp: URI
+    uri_args = {'path': service.full()}
+    uri_args['node'] = node
+    if item_id:
+        uri_args['item'] = item_id
+    template_data['xmpp_uri'] = uri.build_xmpp_uri('pubsub', **uri_args)
+
+
+async def on_data_post(self, request):
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "comment":
+        blog_page = self.get_page_by_name("blog_view")
+        await blog_page.on_data_post(self, request)
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/login/logged/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,18 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server import session_iface
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+"""SàT log-in page, with link to create an account"""
+
+template = "login/logged.html"
+
+
+def prepare_render(self, request):
+    template_data = request.template_data
+    session_data = self.host.get_session_data(request, session_iface.IWebSession)
+    template_data["guest_session"] = session_data.guest
+    template_data["session_started"] = session_data.started
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/login/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,92 @@
+#!/usr/bin/env python3
+
+
+from libervia.backend.core.i18n import _
+from libervia.backend.core import exceptions
+from libervia.web.server.constants import Const as C
+from libervia.web.server import session_iface
+from twisted.internet import defer
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+"""SàT log-in page, with link to create an account"""
+
+name = "login"
+access = C.PAGES_ACCESS_PUBLIC
+template = "login/login.html"
+
+
+def prepare_render(self, request):
+    template_data = request.template_data
+
+    #  we redirect to logged page if a session is active
+    profile = self.get_profile(request)
+    if profile is not None:
+        self.page_redirect("/login/logged", request)
+
+    # login error message
+    session_data = self.host.get_session_data(request, session_iface.IWebSession)
+    login_error = session_data.pop_page_data(self, "login_error")
+    if login_error is not None:
+        template_data["S_C"] = C  # we need server constants in template
+        template_data["login_error"] = login_error
+    template_data["empty_password_allowed"] = bool(
+        self.host.options["empty_password_allowed_warning_dangerous_list"]
+    )
+
+    # register page url
+    if self.host.options["allow_registration"]:
+        template_data["register_url"] = self.get_page_redirect_url(request, "register")
+
+    #  if login is set, we put it in template to prefill field
+    template_data["login"] = session_data.pop_page_data(self, "login")
+
+
+def login_error(self, request, error_const):
+    """set login_error in page data
+
+    @param error_const(unicode): one of login error constant
+    @return C.POST_NO_CONFIRM: avoid confirm message
+    """
+    session_data = self.host.get_session_data(request, session_iface.IWebSession)
+    session_data.set_page_data(self, "login_error", error_const)
+    return C.POST_NO_CONFIRM
+
+
+async def on_data_post(self, request):
+    profile = self.get_profile(request)
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "disconnect":
+        if profile is None:
+            log.warning(_("Disconnect called when no profile is logged"))
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+        else:
+            self.host.purge_session(request)
+            return C.POST_NO_CONFIRM
+    elif type_ == "login":
+        login, password = self.get_posted_data(request, ("login", "password"))
+        try:
+            status = await self.host.connect(request, login, password)
+        except exceptions.ProfileUnknownError:
+            # the profile doesn't exist, we return the same error as for invalid password
+            # to avoid bruteforcing valid profiles
+            log.warning(f"login tentative with invalid profile: {login!r}")
+            return login_error(self, request, C.PROFILE_AUTH_ERROR)
+        except ValueError as e:
+            message = str(e)
+            if message in (C.XMPP_AUTH_ERROR, C.PROFILE_AUTH_ERROR):
+                return login_error(self, request, message)
+            else:
+                # this error was not expected!
+                raise e
+        except exceptions.TimeOutError:
+            return login_error(self, request, C.NO_REPLY)
+        else:
+            if status in (C.PROFILE_LOGGED, C.PROFILE_LOGGED_EXT_JID, C.SESSION_ACTIVE):
+                # Profile has been logged correctly
+                self.redirect_or_continue(request)
+            else:
+                log.error(_("Unhandled status: {status}".format(status=status)))
+    else:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/merge-requests/disco/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,41 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from twisted.words.protocols.jabber import jid
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+name = "merge-requests_disco"
+access = C.PAGES_ACCESS_PUBLIC
+template = "merge-request/discover.html"
+
+
+def prepare_render(self, request):
+    mr_handlers_config = self.host.options["mr_handlers_json"]
+    if mr_handlers_config:
+        handlers = request.template_data["mr_handlers"] = []
+        try:
+            for handler_data in mr_handlers_config:
+                service = handler_data["service"]
+                node = handler_data["node"]
+                name = handler_data["name"]
+                url = self.get_page_by_name("merge-requests").get_url(service, node)
+                handlers.append({"name": name, "url": url})
+        except KeyError as e:
+            log.warning("Missing field in mr_handlers_json: {msg}".format(msg=e))
+        except Exception as e:
+            log.warning("Can't decode mr handlers: {msg}".format(msg=e))
+
+
+def on_data_post(self, request):
+    jid_str = self.get_posted_data(request, "jid")
+    try:
+        jid_ = jid.JID(jid_str)
+    except RuntimeError:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    # for now we just use default node
+    url = self.get_page_by_name("merge-requests").get_url(jid_.full(), "@")
+    self.http_redirect(request, url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/merge-requests/edit/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,125 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+"""merge-requests edition"""
+
+name = "merge-requests_edit"
+access = C.PAGES_ACCESS_PROFILE
+template = "merge-request/edit.html"
+log = getLogger(__name__)
+
+
+def parse_url(self, request):
+    try:
+        item_id = self.next_path(request)
+    except IndexError:
+        log.warning(_("no list item id specified"))
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    data = self.get_r_data(request)
+    data["list_item_id"] = item_id
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    service, node, list_item_id = (
+        data.get("service", ""),
+        data.get("node", ""),
+        data["list_item_id"],
+    )
+    profile = self.get_profile(request)
+
+    ignore = (
+        "publisher",
+        "author",
+        "author_jid",
+        "author_email",
+        "created",
+        "updated",
+        "comments_uri",
+        "request_data",
+        "type",
+    )
+    merge_requests = data_format.deserialise(
+        await self.host.bridge_call(
+            "merge_requests_get",
+            service.full() if service else "",
+            node,
+            C.NO_LIMIT,
+            [list_item_id],
+            "",
+            data_format.serialise({}),
+            profile,
+        )
+    )
+    list_item = template_xmlui.create(
+        self.host, merge_requests['items'][0], ignore=ignore
+    )
+
+    try:
+        # small trick to get a one line text input instead of the big textarea
+        list_item.widgets["labels"].type = "string"
+        list_item.widgets["labels"].value = list_item.widgets["labels"].value.replace(
+            "\n", ", "
+        )
+    except KeyError:
+        pass
+
+    # same as list_edit
+    wid = list_item.widgets['body']
+    if wid.type == "xhtmlbox":
+        wid.type = "textbox"
+        wid.value =  await self.host.bridge_call(
+            "syntax_convert", wid.value, C.SYNTAX_XHTML, "markdown",
+            False, profile)
+
+    template_data["new_list_item_xmlui"] = list_item
+
+
+async def on_data_post(self, request):
+    data = self.get_r_data(request)
+    service = data["service"]
+    node = data["node"]
+    list_item_id = data["list_item_id"]
+    posted_data = self.get_all_posted_data(request)
+    if not posted_data["title"] or not posted_data["body"]:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    try:
+        posted_data["labels"] = [l.strip() for l in posted_data["labels"][0].split(",")]
+    except (KeyError, IndexError):
+        pass
+    profile = self.get_profile(request)
+
+    # we convert back body to XHTML
+    body = await self.host.bridge_call(
+        "syntax_convert", 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}
+    await self.host.bridge_call(
+        "merge_request_set",
+        service.full(),
+        node,
+        "",
+        "auto",
+        posted_data,
+        "",
+        list_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.get_page_by_name("merge-requests"),
+        service.full(),
+        node or "@",
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/merge-requests/new/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,12 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+name = "merge-requests_new"
+access = C.PAGES_ACCESS_PUBLIC
+template = "merge-request/create.html"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/merge-requests/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import data_objects
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+
+name = "merge-requests"
+access = C.PAGES_ACCESS_PUBLIC
+template = "list/overview.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "node"], service="jid")
+    data = self.get_r_data(request)
+    service, node = data["service"], data["node"]
+    if node is None:
+        self.page_redirect("merge-requests_disco", request)
+    if node == "@":
+        node = data["node"] = ""
+    self.check_cache(
+        request, C.CACHE_PUBSUB, service=service, node=node, short="merge-requests"
+    )
+    template_data = request.template_data
+    template_data["url_list_items"] = self.get_page_by_name("merge-requests").get_url(
+        service.full(), node
+    )
+    template_data["url_list_new"] = self.get_sub_page_url(request, "merge-requests_new")
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    service, node = data["service"], data["node"]
+    profile = self.get_profile(request) or C.SERVICE_PROFILE
+
+    merge_requests = data_format.deserialise(
+        await self.host.bridge_call(
+            "merge_requests_get",
+            service.full() if service else "",
+            node,
+            C.NO_LIMIT,
+            [],
+            "",
+            data_format.serialise({"labels_as_list": C.BOOL_TRUE}),
+            profile,
+        )
+    )
+
+    template_data["list_items"] = [
+        template_xmlui.create(self.host, x) for x in merge_requests['items']
+    ]
+    template_data["on_list_item_click"] = data_objects.OnClick(
+        url=self.get_sub_page_url(request, "merge-requests_view") + "/{item.id}"
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/merge-requests/view/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,98 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.backend.core.i18n import _
+from libervia.web.server.utils import SubPage
+from libervia.web.server import session_iface
+from twisted.words.protocols.jabber import jid
+from libervia.backend.tools.common import template_xmlui
+from libervia.backend.tools.common import uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.core.log import getLogger
+
+name = "merge-requests_view"
+access = C.PAGES_ACCESS_PUBLIC
+template = "merge-request/item.html"
+log = getLogger(__name__)
+
+
+def parse_url(self, request):
+    try:
+        item_id = self.next_path(request)
+    except IndexError:
+        log.warning(_("no list item id specified"))
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+
+    data = self.get_r_data(request)
+    data["list_item_id"] = item_id
+
+
+async def prepare_render(self, request):
+    data = self.get_r_data(request)
+    template_data = request.template_data
+    session = self.host.get_session_data(request, session_iface.IWebSession)
+    service, node, list_item_id = (
+        data.get("service", ""),
+        data.get("node", ""),
+        data["list_item_id"],
+    )
+    profile = self.get_profile(request)
+
+    if profile is None:
+        profile = C.SERVICE_PROFILE
+
+    merge_requests = data_format.deserialise(
+        await self.host.bridge_call(
+            "merge_requests_get",
+            service.full() if service else "",
+            node,
+            C.NO_LIMIT,
+            [list_item_id],
+            "",
+            data_format.serialise({"parse": C.BOOL_TRUE, "labels_as_list": C.BOOL_TRUE}),
+            profile,
+        )
+    )
+    list_item = template_xmlui.create(
+        self.host, merge_requests['items'][0], ignore=["request_data", "type"]
+    )
+    template_data["item"] = list_item
+    template_data["patches"] = merge_requests['items_patches'][0]
+    comments_uri = list_item.widgets["comments_uri"].value
+    if comments_uri:
+        uri_data = uri.parse_xmpp_uri(comments_uri)
+        template_data["comments_node"] = comments_node = uri_data["node"]
+        template_data["comments_service"] = comments_service = uri_data["path"]
+        template_data["comments"] = data_format.deserialise(await self.host.bridge_call(
+            "mb_get", comments_service, comments_node, C.NO_LIMIT, [],
+            data_format.serialise({}), profile
+        ))
+
+        template_data["login_url"] = self.get_page_redirect_url(request)
+
+    if session.connected:
+        # we set edition URL only if user is the publisher or the node owner
+        publisher = jid.JID(list_item.widgets["publisher"].value)
+        is_publisher = publisher.userhostJID() == session.jid.userhostJID()
+        affiliation = None
+        if not is_publisher:
+            node = node or self.host.ns_map["merge_requests"]
+            affiliation = await self.host.get_affiliation(request, service, node)
+        if is_publisher or affiliation == "owner":
+            template_data["url_list_item_edit"] = self.get_url_by_path(
+                SubPage("merge-requests"),
+                service.full(),
+                node or "@",
+                SubPage("merge-requests_edit"),
+                list_item_id,
+            )
+
+
+async def on_data_post(self, request):
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "comment":
+        blog_page = self.get_page_by_name("blog_view")
+        await blog_page.on_data_post(self, request)
+    else:
+        log.warning(_("Unhandled data type: {}").format(type_))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/photos/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,62 @@
+from browser import window, bind, DOMNode
+from javascript import JSON
+from bridge import Bridge
+import dialog
+
+bridge = Bridge()
+
+
+def album_delete_cb(item_elt, item):
+    print(f"deleted {item['name']}")
+
+
+def album_delete_eb(failure, item_elt, item):
+    # TODO: cleaner error notification
+    window.alert(f"error while deleting {item['name']}: failure")
+
+
+def interest_retract_cb(item_elt, item):
+    print(f"{item['name']} removed successfuly from list of interests")
+    item_elt.classList.add("state_deleted")
+    item_elt.bind("transitionend", lambda evt: item_elt.remove())
+    bridge.file_sharing_delete(
+        item['service'],
+        item.get('path', ''),
+        item.get('files_namespace', ''),
+        callback=lambda __: album_delete_cb(item_elt, item),
+        errback=lambda failure: album_delete_eb(failure, item_elt, item),
+    )
+
+
+def interest_retract_eb(failure_, item_elt, item):
+    # TODO: cleaner error notification
+    window.alert(f"Can't delete album {item['name']}: {failure_['message']}")
+
+
+def delete_ok(evt, notif_elt, item_elt, item):
+    bridge.interest_retract(
+        "", item['id'],
+        callback=lambda: interest_retract_cb(item_elt, item),
+        errback=lambda failure:interest_retract_eb(failure, item_elt, item))
+
+
+def delete_cancel(evt, notif_elt, item_elt, item):
+    notif_elt.remove()
+    item_elt.classList.remove("selected_for_deletion")
+
+
+@bind(".action_delete", "click")
+def on_delete(evt):
+    evt.stopPropagation()
+    target = evt.currentTarget
+    item_elt = DOMNode(target.closest('.item'))
+    item_elt.classList.add("selected_for_deletion")
+    item = JSON.parse(item_elt.dataset.item)
+    dialog.Confirm(
+        f"album {item['name']!r} will be deleted (inluding all its photos), "
+        f"are you sure?",
+        ok_label="delete",
+    ).show(
+        ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item),
+        cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item),
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/photos/album/_browser/__init__.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,305 @@
+from browser import document, window, bind, html, DOMNode, aio
+from javascript import JSON
+from bridge import Bridge, AsyncBridge
+from template import Template
+import dialog
+from slideshow import SlideShow
+from invitation import InvitationManager
+import alt_media_player
+# we use tmp_aio because `blob` is not handled in Brython's aio
+import tmp_aio
+import loading
+
+
+cache_path = window.cache_path
+files_service = window.files_service
+files_path = window.files_path
+try:
+    affiliations = window.affiliations.to_dict()
+except AttributeError:
+    pass
+bridge = Bridge()
+async_bridge = AsyncBridge()
+
+alt_media_player.install_if_needed()
+
+photo_tpl = Template('photo/item.html')
+player_tpl = Template('components/media_player.html')
+
+# file upload
+
+def on_progress(ev, photo_elt):
+    if ev.lengthComputable:
+        percent = int(ev.loaded/ev.total*100)
+        update_progress(photo_elt, percent)
+
+
+def on_load(file_, photo_elt):
+    update_progress(photo_elt, 100)
+    photo_elt.classList.add("progress_finished")
+    photo_elt.classList.remove("progress_started")
+    photo_elt.select_one('.action_delete').bind("click", on_delete)
+    print(f"file {file_.name} uploaded correctly")
+
+
+def on_error(failure, file_, photo_elt):
+    dialog.notification.show(
+        f"can't upload {file_.name}: {failure}",
+        level="error"
+    )
+
+
+def update_progress(photo_elt, new_value):
+    progress_elt = photo_elt.select_one("progress")
+    progress_elt.value = new_value
+    progress_elt.text = f"{new_value}%"
+
+
+def on_slot_cb(file_, upload_slot, photo_elt):
+    put_url, get_url, headers = upload_slot
+    xhr = window.XMLHttpRequest.new()
+    xhr.open("PUT", put_url, True)
+    xhr.upload.bind('progress', lambda ev: on_progress(ev, photo_elt))
+    xhr.upload.bind('load', lambda ev: on_load(file_, photo_elt))
+    xhr.upload.bind('error', lambda ev: on_error(xhr.response, file_, photo_elt))
+    xhr.setRequestHeader('Xmpp-File-Path', window.encodeURIComponent(files_path))
+    xhr.setRequestHeader('Xmpp-File-No-Http', "true")
+    xhr.send(file_)
+
+
+def on_slot_eb(file_, failure, photo_elt):
+    dialog.notification.show(
+        f"Can't get upload slot: {failure['message']}",
+        level="error"
+    )
+    photo_elt.remove()
+
+
+def upload_files(files):
+    print(f"uploading {len(files)} files")
+    album_items = document['album_items']
+    for file_ in files:
+        url = window.URL.createObjectURL(file_)
+        photo_elt = photo_tpl.get_elt({
+            "file": {
+                "name": file_.name,
+                # we don't want to open the file on click, it's not yet the
+                # uploaded URL
+                "url": url,
+                # we have no thumb yet, so we use the whole image
+                # TODO: reduce image for preview
+                "thumb_url": url,
+            },
+            "uploading": True,
+        })
+        photo_elt.classList.add("progress_started")
+        album_items <= photo_elt
+
+        bridge.file_http_upload_get_slot(
+            file_.name,
+            file_.size,
+            file_.type or '',
+            files_service,
+            callback=lambda upload_slot, file_=file_, photo_elt=photo_elt:
+                on_slot_cb(file_, upload_slot, photo_elt),
+            errback=lambda failure, file_=file_, photo_elt=photo_elt:
+                on_slot_eb(file_, failure, photo_elt),
+        )
+
+
+@bind("#file_drop", "drop")
+def on_file_select(evt):
+    evt.stopPropagation()
+    evt.preventDefault()
+    files = evt.dataTransfer.files
+    upload_files(files)
+
+
+@bind("#file_drop", "dragover")
+def on_drag_over(evt):
+    evt.stopPropagation()
+    evt.preventDefault()
+    evt.dataTransfer.dropEffect = 'copy'
+
+
+@bind("#file_input", "change")
+def on_file_input_change(evt):
+    files = evt.currentTarget.files
+    upload_files(files)
+
+# delete
+
+def file_delete_cb(item_elt, item):
+    item_elt.classList.add("state_deleted")
+    item_elt.bind("transitionend", lambda evt: item_elt.remove())
+    print(f"deleted {item['name']}")
+
+
+def file_delete_eb(failure, item_elt, item):
+    dialog.notification.show(
+        f"error while deleting {item['name']}: failure",
+        level="error"
+    )
+
+
+def delete_ok(evt, notif_elt, item_elt, item):
+    file_path = f"{files_path.rstrip('/')}/{item['name']}"
+    bridge.file_sharing_delete(
+        files_service,
+        file_path,
+        "",
+        callback=lambda : file_delete_cb(item_elt, item),
+        errback=lambda failure: file_delete_eb(failure, item_elt, item),
+    )
+
+
+def delete_cancel(evt, notif_elt, item_elt, item):
+    notif_elt.remove()
+    item_elt.classList.remove("selected_for_deletion")
+
+
+def on_delete(evt):
+    evt.stopPropagation()
+    target = evt.currentTarget
+    item_elt = DOMNode(target.closest('.item'))
+    item_elt.classList.add("selected_for_deletion")
+    item = JSON.parse(item_elt.dataset.item)
+    dialog.Confirm(
+        f"{item['name']!r} will be deleted, are you sure?",
+        ok_label="delete",
+        ok_color="danger",
+    ).show(
+        ok_cb=lambda evt, notif_elt: delete_ok(evt, notif_elt, item_elt, item),
+        cancel_cb=lambda evt, notif_elt: delete_cancel(evt, notif_elt, item_elt, item),
+    )
+
+# cover
+
+async def cover_ok(evt, notif_elt, item_elt, item):
+    # we first need to get a blob of the image
+    img_elt = item_elt.select_one("img")
+    # the simplest way is to download it
+    r = await tmp_aio.ajax("GET", img_elt.src, "blob")
+    if r.status != 200:
+        dialog.notification.show(
+            f"can't retrieve cover: {r.status}: {r.statusText}",
+            level="error"
+        )
+        return
+    img_blob = r.response
+    # now we'll upload it via HTTP Upload, we need a slow
+    img_name = img_elt.src.rsplit('/', 1)[-1]
+    img_size = img_blob.size
+
+    slot = await async_bridge.file_http_upload_get_slot(
+        img_name,
+        img_size,
+        '',
+        files_service
+    )
+    get_url, put_url, headers = slot
+    # we have the slot, we can upload image
+    r = await tmp_aio.ajax("PUT", put_url, "", img_blob)
+    if r.status != 201:
+        dialog.notification.show(
+            f"can't upload cover: {r.status}: {r.statusText}",
+            level="error"
+        )
+        return
+    extra = {"thumb_url": get_url}
+    album_name = files_path.rsplit('/', 1)[-1]
+    await async_bridge.interests_file_sharing_register(
+        files_service,
+        "photos",
+        "",
+        files_path,
+        album_name,
+        JSON.stringify(extra),
+    )
+    dialog.notification.show("Album cover has been changed")
+
+
+def cover_cancel(evt, notif_elt, item_elt, item):
+    notif_elt.remove()
+    item_elt.classList.remove("selected_for_action")
+
+
+def on_cover(evt):
+    evt.stopPropagation()
+    target = evt.currentTarget
+    item_elt = DOMNode(target.closest('.item'))
+    item_elt.classList.add("selected_for_action")
+    item = JSON.parse(item_elt.dataset.item)
+    dialog.Confirm(
+        f"use {item['name']!r} for this album cover?",
+        ok_label="use as cover",
+    ).show(
+        ok_cb=lambda evt, notif_elt: aio.run(cover_ok(evt, notif_elt, item_elt, item)),
+        cancel_cb=lambda evt, notif_elt: cover_cancel(evt, notif_elt, item_elt, item),
+    )
+
+
+# slideshow
+
+@bind(".photo_thumb_click", "click")
+def photo_click(evt):
+    evt.stopPropagation()
+    evt.preventDefault()
+    slideshow = SlideShow()
+    target = evt.currentTarget
+    clicked_item_elt = DOMNode(target.closest('.item'))
+
+    slideshow.attach()
+    for idx, item_elt in enumerate(document.select('.item')):
+        item = JSON.parse(item_elt.dataset.item)
+        try:
+            biggest_thumb = item['extra']['thumbnails'][-1]
+            thumb_url = f"{cache_path}{biggest_thumb['filename']}"
+        except (KeyError, IndexError) as e:
+            print(f"Can't get full screen thumbnail URL: {e}")
+            thumb_url = None
+        if item.get("mime_type", "")[:5] == "video":
+            player = alt_media_player.MediaPlayer(
+                [item['url']],
+                poster = thumb_url,
+                reduce_click_area =  True
+            )
+            elt = player.elt
+            elt.classList.add("slide_video", "no_fullscreen")
+            slideshow.add_slide(
+                elt,
+                item,
+                options={
+                    "flags": (alt_media_player.NO_PAGINATION, alt_media_player.NO_SCROLLBAR),
+                    "exit_callback": player.reset,
+                }
+            )
+        else:
+            slideshow.add_slide(html.IMG(src=thumb_url or item['url'], Class="slide_img"), item)
+        if item_elt == clicked_item_elt:
+            slideshow.index = idx
+
+
+for elt in document.select('.action_delete'):
+    elt.bind("click", on_delete)
+for elt in document.select('.action_cover'):
+    elt.bind("click", on_cover)
+
+# manage
+
+
+@bind("#button_manage", "click")
+def manage_click(evt):
+    evt.stopPropagation()
+    evt.preventDefault()
+    manager = InvitationManager("photos", {"service": files_service, "path": files_path})
+    manager.attach(affiliations=affiliations)
+
+
+# hint
+@bind("#hint .click_to_delete", "click")
+def remove_hint(evt):
+    document['hint'].remove()
+
+
+loading.remove_loading_screen()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/photos/album/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,30 @@
+#!/usr/bin/env python3
+
+
+from libervia.backend.core.i18n import D_
+from libervia.backend.core.log import getLogger
+from libervia.web.server.constants import Const as C
+
+log = getLogger(__name__)
+
+name = "photos_album"
+label = D_("Photos Album")
+access = C.PAGES_ACCESS_PROFILE
+template = "photo/album.html"
+
+
+def parse_url(self, request):
+    self.get_path_args(request, ["service", "*path"], min_args=1, service="jid", path="")
+
+
+def prepare_render(self, request):
+    data = self.get_r_data(request)
+    data["thumb_limit"] = 800
+    data["retrieve_comments"] = True
+    files_page = self.get_page_by_name("files_list")
+    return files_page.prepare_render(self, request)
+
+
+def on_data_post(self, request):
+    blog_page = self.get_page_by_name("blog_view")
+    return blog_page.on_data_post(self, request)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/photos/new/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+
+from libervia.web.server.constants import Const as C
+from twisted.internet import defer
+from libervia.backend.core.log import getLogger
+from libervia.backend.core.i18n import D_
+from libervia.backend.core import exceptions
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+
+"""creation of new events"""
+
+name = "photos_new"
+access = C.PAGES_ACCESS_PROFILE
+template = "photo/create.html"
+log = getLogger(__name__)
+
+
+async def on_data_post(self, request):
+    request_data = self.get_r_data(request)
+    profile = self.get_profile(request)
+    name = self.get_posted_data(request, "name").replace('/', '_')
+    albums_path = "/albums"
+    album_path = f"{albums_path}/{name}"
+    if profile is None:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
+    fis_ns = self.host.ns_map["fis"]
+    http_upload_ns = self.host.ns_map["http_upload"]
+    entities_services, __, __ = await self.host.bridge_call(
+        "disco_find_by_features",
+        [fis_ns, http_upload_ns],
+        [],
+        False,
+        True,
+        False,
+        False,
+        False,
+        profile
+    )
+    try:
+        fis_service = next(iter(entities_services))
+    except StopIteration:
+        raise exceptions.DataError(D_(
+           "You server has no service to create a photo album, please ask your server "
+           "administrator to add one"))
+
+    try:
+        await self.host.bridge_call(
+            "fis_create_dir",
+            fis_service,
+            "",
+            albums_path,
+            {"access_model": "open"},
+            profile
+        )
+    except BridgeException as e:
+        if e.condition == 'conflict':
+            pass
+        else:
+            log.error(f"Can't create {albums_path} path: {e}")
+            raise e
+
+    try:
+        await self.host.bridge_call(
+            "fis_create_dir",
+            fis_service,
+            "",
+            album_path,
+            {"access_model": "whitelist"},
+            profile
+        )
+    except BridgeException as e:
+        if e.condition == 'conflict':
+            pass
+        else:
+            log.error(f"Can't create {album_path} path: {e}")
+            raise e
+
+    await self.host.bridge_call(
+        "interests_file_sharing_register",
+        fis_service,
+        "photos",
+        "",
+        album_path,
+        name,
+        "",
+        profile
+    )
+    log.info(f"album {name} created")
+    request_data["post_redirect_page"] = self.get_page_by_name("photos")
+    defer.returnValue(C.POST_NO_CONFIRM)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/photos/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,53 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from twisted.internet import defer
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+name = "photos"
+access = C.PAGES_ACCESS_PROFILE
+template = "photo/discover.html"
+
+
+@defer.inlineCallbacks
+def prepare_render(self, request):
+    profile = self.get_profile(request)
+    template_data = request.template_data
+    namespace = self.host.ns_map["fis"]
+    if profile is not None:
+        try:
+            interests = yield self.host.bridge_call(
+                "interests_list", "", "", namespace, profile)
+        except Exception:
+            log.warning(_("Can't get interests list for {profile}").format(
+                profile=profile))
+        else:
+            # we only want photo albums
+            filtered_interests = []
+            for interest in interests:
+                if interest.get('subtype') != 'photos':
+                    continue
+                path = interest.get('path', '')
+                path_args = [p for p in path.split('/') if p]
+                interest["url"] = self.get_sub_page_url(
+                    request,
+                    "photos_album",
+                    interest['service'],
+                    *path_args
+                )
+                filtered_interests.append(interest)
+
+            template_data['interests'] = filtered_interests
+
+        template_data["url_photos_new"] = self.get_sub_page_url(request, "photos_new")
+
+
+@defer.inlineCallbacks
+def on_data_post(self, request):
+    jid_ = self.get_posted_data(request, "jid")
+    url = self.get_page_by_name("photos_album").get_url(jid_)
+    self.http_redirect(request, url)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/register/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,63 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from libervia.web.server import session_iface
+from twisted.internet import defer
+from libervia.backend.core.log import getLogger
+
+log = getLogger(__name__)
+
+"""SàT account registration page"""
+
+name = "register"
+access = C.PAGES_ACCESS_PUBLIC
+template = "login/register.html"
+
+
+def prepare_render(self, request):
+    if not self.host.options["allow_registration"]:
+        self.page_error(request, C.HTTP_FORBIDDEN)
+    profile = self.get_profile(request)
+    if profile is not None:
+        self.page_redirect("/login/logged", request)
+    template_data = request.template_data
+    template_data["login_url"] = self.get_page_by_name("login").url
+    template_data["S_C"] = C  # we need server constants in template
+
+    # login error message
+    session_data = self.host.get_session_data(request, session_iface.IWebSession)
+    login_error = session_data.pop_page_data(self, "login_error")
+    if login_error is not None:
+        template_data["login_error"] = login_error
+
+    #  if fields were already filled, we reuse them
+    for k in ("login", "email", "password"):
+        template_data[k] = session_data.pop_page_data(self, k)
+
+
+@defer.inlineCallbacks
+def on_data_post(self, request):
+    type_ = self.get_posted_data(request, "type")
+    if type_ == "register":
+        login, email, password = self.get_posted_data(
+            request, ("login", "email", "password")
+        )
+        status = yield self.host.register_new_account(request, login, password, email)
+        session_data = self.host.get_session_data(request, session_iface.IWebSession)
+        if status == C.REGISTRATION_SUCCEED:
+            # we prefill login field for login page
+            session_data.set_page_data(self.get_page_by_name("login"), "login", login)
+            # if we have a redirect_url we follow it
+            self.redirect_or_continue(request)
+            # else we redirect to login page
+            self.http_redirect(request, self.get_page_by_name("login").url)
+        else:
+            session_data.set_page_data(self, "login_error", status)
+            l = locals()
+            for k in ("login", "email", "password"):
+                # we save fields so user doesn't have to enter them again
+                session_data.set_page_data(self, k, l[k])
+            defer.returnValue(C.POST_NO_CONFIRM)
+    else:
+        self.page_error(request, C.HTTP_BAD_REQUEST)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/u/atom.xml/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,4 @@
+#!/usr/bin/env python3
+
+redirect = "blog_feed_atom"
+name = "user_blog_feed_atom"
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/u/blog/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,15 @@
+#!/usr/bin/env python3
+
+
+name = "user_blog"
+
+
+def parse_url(self, request):
+    # in this subpage, we want path args and query args
+    # (i.e. what's remaining in URL: filters, id, etc.)
+    # to be used by blog's url parser, so we don't skip parse_url
+    data = self.get_r_data(request)
+    service = data["service"]
+    self.page_redirect(
+        "blog_view", request, skip_parse_url=False, path_args=[service.full(), "@"]
+    )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/pages/u/page_meta.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,57 @@
+#!/usr/bin/env python3
+
+
+from libervia.web.server.constants import Const as C
+from twisted.internet import defer
+from twisted.words.protocols.jabber import jid
+
+"""page used to target a user profile, e.g. for public blog"""
+
+name = "user"
+access = C.PAGES_ACCESS_PUBLIC  # can be a callable
+template = "blog/articles.html"
+url_cache = True
+
+
+@defer.inlineCallbacks
+def parse_url(self, request):
+    try:
+        prof_requested = self.next_path(request)
+    except IndexError:
+        self.page_error(request)
+
+    data = self.get_r_data(request)
+
+    target_profile = yield self.host.bridge_call("profile_name_get", prof_requested)
+    request.template_data["target_profile"] = target_profile
+    target_jid = yield self.host.bridge_call(
+        "param_get_a_async", "JabberID", "Connection", "value", profile_key=target_profile
+    )
+    target_jid = jid.JID(target_jid)
+    data["service"] = target_jid
+
+    # if URL is parsed here, we'll have atom.xml available and we need to
+    # add the link to the page
+    atom_url = self.get_sub_page_url(request, 'user_blog_feed_atom')
+    request.template_data['atom_url'] = atom_url
+    request.template_data.setdefault('links', []).append({
+        "href": atom_url,
+        "type": "application/atom+xml",
+        "rel": "alternate",
+        "title": "{target_profile}'s blog".format(target_profile=target_profile)})
+
+def add_breadcrumb(self, request, breadcrumbs):
+    # we don't want a breadcrumb here
+    pass
+
+
+@defer.inlineCallbacks
+def prepare_render(self, request):
+    data = self.get_r_data(request)
+    self.check_cache(
+        request, C.CACHE_PUBSUB, service=data["service"], node=None, short="microblog"
+    )
+    self.page_redirect("blog_view", request)
+
+def on_data_post(self, request):
+    return self.get_page_by_name("blog_view").on_data_post(self, request)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/classes.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,27 @@
+#!/usr/bin/env python3
+
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Useful genertic classes used in Libervia"""
+
+
+from collections import namedtuple
+
+WebsocketMeta = namedtuple("WebsocketMeta", ("url", "token", "debug"))
+Notification = namedtuple("Notification", ("message", "level"))
+Script = namedtuple("Script", ("src", "type", "content"), defaults=(None, None, ""))
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/constants.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,138 @@
+#!/usr/bin/env python3
+
+# Libervia: a SàT frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from ..common import constants
+
+
+class Const(constants.Const):
+
+    APP_NAME = "Libervia Web"
+    APP_COMPONENT = "web"
+    APP_NAME_ALT = APP_NAME
+    APP_NAME_FILE = "libervia_web"
+    CONFIG_SECTION = APP_COMPONENT.lower()
+    # 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/"
+    THEMES_DIR = "themes/"
+    THEMES_URL = "themes"
+    MEDIA_DIR = "media/"
+    CARDS_DIR = "games/cards/tarot"
+    PAGES_DIR = "pages"
+    TASKS_DIR = "tasks"
+    LIBERVIA_CACHE = "libervia"
+    SITE_NAME_DEFAULT = "default"
+    # generated files will be accessible there
+    BUILD_DIR = "__b"
+    BUILD_DIR_DYN = "dyn"
+    # directory where build files are served to the client
+    PRODUCTION_BUILD_DIR = "sites"
+    # directory used for files needed temporarily (e.g. for compiling other files)
+    DEV_BUILD_DIR = "dev_build"
+
+    TPL_RESOURCE = '_t'
+
+    ERRNUM_BRIDGE_ERRBACK = 0  # FIXME
+    ERRNUM_LIBERVIA = 0  # FIXME
+
+    # Security limit for Libervia (get/set params)
+    SECURITY_LIMIT = 5
+
+    # Security limit for Libervia server_side
+    SERVER_SECURITY_LIMIT = constants.Const.NO_SECURITY_LIMIT
+
+    # keys for cache values we can get from browser
+    ALLOWED_ENTITY_DATA = {"avatar", "nick"}
+
+    STATIC_RSM_MAX_LIMIT = 100
+    STATIC_RSM_MAX_DEFAULT = 10
+    STATIC_RSM_MAX_COMMENTS_DEFAULT = 10
+
+    ## Libervia pages ##
+    PAGES_META_FILE = "page_meta.py"
+    PAGES_BROWSER_DIR = "_browser"
+    PAGES_BROWSER_META_FILE = "browser_meta.json"
+    PAGES_ACCESS_NONE = (
+        "none"
+    )  #  no access to this page (using its path will return a 404 error)
+    PAGES_ACCESS_PUBLIC = "public"
+    PAGES_ACCESS_PROFILE = (
+        "profile"
+    )  # a session with an existing profile must be started
+    PAGES_ACCESS_ADMIN = "admin"  #  only profiles set in admins_list can access the page
+    PAGES_ACCESS_ALL = (
+        PAGES_ACCESS_NONE,
+        PAGES_ACCESS_PUBLIC,
+        PAGES_ACCESS_PROFILE,
+        PAGES_ACCESS_ADMIN,
+    )
+    # names of the page to use for menu
+    DEFAULT_MENU = [
+        "login",
+        "chat",
+        "blog",
+        "forums",
+        "photos",
+        "files",
+        "calendar",
+        "events",
+        "lists",
+        "merge-requests",
+        "calls"
+        # XXX: app is not available anymore since removal of pyjamas code with Python 3
+        #    port. It should come back at a later point with an alternative (Brython
+        #    probably).
+    ]
+
+    ## Session flags ##
+    FLAG_CONFIRM = "CONFIRM"
+
+    ## Data post ##
+    POST_NO_CONFIRM = "POST_NO_CONFIRM"
+
+    ## HTTP methods ##
+    HTTP_METHOD_GET = b"GET"
+    HTTP_METHOD_POST = b"POST"
+
+    ## HTTP codes ##
+    HTTP_SEE_OTHER = 303
+    HTTP_NOT_MODIFIED = 304
+    HTTP_BAD_REQUEST = 400
+    HTTP_UNAUTHORIZED = 401
+    HTTP_FORBIDDEN = 403
+    HTTP_NOT_FOUND = 404
+    HTTP_INTERNAL_ERROR = 500
+    HTTP_PROXY_ERROR = 502
+    HTTP_SERVICE_UNAVAILABLE = 503
+
+    ## HTTP HEADERS ##
+    H_FORWARDED = "Forwarded"
+    H_X_FORWARDED_FOR = "X-Forwarded-For"
+    H_X_FORWARDED_HOST = "X-Forwarded-Host"
+    H_X_FORWARDED_PROTO = "X-Forwarded-Proto"
+
+
+    ## Cache ##
+    CACHE_PUBSUB = 0
+
+    ## Date/Time ##
+    HTTP_DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
+    HTTP_MONTH = ("Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct",
+                  "Nov", "Dec")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/html_tools.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,36 @@
+#!/usr/bin/env python3
+
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+def sanitize_html(text):
+    """Sanitize HTML by escaping everything"""
+    # this code comes from official python wiki: http://wiki.python.org/moin/EscapingHtml
+    html_escape_table = {
+        "&": "&amp;",
+        '"': "&quot;",
+        "'": "&apos;",
+        ">": "&gt;",
+        "<": "&lt;",
+    }
+
+    return "".join(html_escape_table.get(c, c) for c in text)
+
+
+def convert_new_lines_to_xhtml(text):
+    return text.replace("\n", "<br/>")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/launcher.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,31 @@
+#!/usr/bin/env python3
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Script launching Libervia server"""
+
+from libervia.backend.core import launcher
+from libervia.web.server.constants import Const as C
+
+
+class Launcher(launcher.Launcher):
+    APP_NAME=C.APP_NAME
+    APP_NAME_FILE=C.APP_NAME_FILE
+
+
+if __name__ == '__main__':
+    Launcher.run()
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/pages.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,1860 @@
+#!/usr/bin/env python3
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from __future__ import annotations
+import copy
+from functools import reduce
+import hashlib
+import json
+import os.path
+from pathlib import Path
+import time
+import traceback
+from typing import List, Optional, Union
+import urllib.error
+import urllib.parse
+import urllib.request
+import uuid
+
+from twisted.internet import defer
+from twisted.python import failure
+from twisted.python.filepath import FilePath
+from twisted.web import server
+from twisted.web import resource as web_resource
+from twisted.web import util as web_util
+from twisted.words.protocols.jabber import jid
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import date_utils
+from libervia.backend.tools.common import utils
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.utils import as_deferred
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+
+from . import session_iface
+from .classes import WebsocketMeta
+from .classes import Script
+from .constants import Const as C
+from .resources import LiberviaRootResource
+from .utils import SubPage, quote
+
+log = getLogger(__name__)
+
+
+class CacheBase(object):
+    def __init__(self):
+        self._created = time.time()
+        self._last_access = self._created
+
+    @property
+    def created(self):
+        return self._created
+
+    @property
+    def last_access(self):
+        return self._last_access
+
+    @last_access.setter
+    def last_access(self, timestamp):
+        self._last_access = timestamp
+
+
+class CachePage(CacheBase):
+    def __init__(self, rendered):
+        super(CachePage, self).__init__()
+        self._created = time.time()
+        self._last_access = self._created
+        self._rendered = rendered
+        self._hash = hashlib.sha256(rendered).hexdigest()
+
+    @property
+    def rendered(self):
+        return self._rendered
+
+    @property
+    def hash(self):
+        return self._hash
+
+
+class CacheURL(CacheBase):
+    def __init__(self, request):
+        super(CacheURL, self).__init__()
+        try:
+            self._data = copy.deepcopy(request.data)
+        except AttributeError:
+            self._data = {}
+        self._template_data = copy.deepcopy(request.template_data)
+        self._prepath = request.prepath[:]
+        self._postpath = request.postpath[:]
+        del self._template_data["csrf_token"]
+
+    def use(self, request):
+        self.last_access = time.time()
+        request.data = copy.deepcopy(self._data)
+        request.template_data.update(copy.deepcopy(self._template_data))
+        request.prepath = self._prepath[:]
+        request.postpath = self._postpath[:]
+
+
+class LiberviaPage(web_resource.Resource):
+    isLeaf = True  #  we handle subpages ourself
+    cache = {}
+    #  Set of tuples (service/node/sub_id) of nodes subscribed for caching
+    # sub_id can be empty string if not handled by service
+    cache_pubsub_sub = set()
+
+    def __init__(
+        self, host, vhost_root, root_dir, url, name=None, label=None, redirect=None,
+        access=None, dynamic=True, parse_url=None, add_breadcrumb=None,
+        prepare_render=None, render=None, template=None, on_data_post=None, on_data=None,
+        url_cache=False, replace_on_conflict=False
+        ):
+        """Initiate LiberviaPage instance
+
+        LiberviaPages are the main resources of Libervia, using easy to set python files
+        The non mandatory arguments are the variables found in page_meta.py
+        @param host(Libervia): the running instance of Libervia
+        @param vhost_root(web_resource.Resource): root resource of the virtual host which
+            handle this page.
+        @param root_dir(Path): absolute file path of the page
+        @param url(unicode): relative URL to the page
+            this URL may not be valid, as pages may require path arguments
+        @param name(unicode, None): if not None, a unique name to identify the page
+            can then be used for e.g. redirection
+            "/" is not allowed in names (as it can be used to construct URL paths)
+        @param redirect(unicode, None): if not None, this page will be redirected.
+            A redirected parameter is used as in self.page_redirect.
+            parse_url will not be skipped
+            using this redirect parameter is called "full redirection"
+            using self.page_redirect is called "partial redirection" (because some
+            rendering method can still be used, e.g. parse_url)
+        @param access(unicode, None): permission needed to access the page
+            None means public access.
+            Pages inherit from parent pages: e.g. if a "settings" page is restricted
+            to admins, and if "settings/blog" is public, it still can only be accessed by
+            admins. See C.PAGES_ACCESS_* for details
+        @param dynamic(bool): if True, activate websocket for bidirectional communication
+        @param parse_url(callable, None): if set it will be called to handle the URL path
+            after this method, the page will be rendered if noting is left in path
+            (request.postpath) else a the request will be transmitted to a subpage
+        @param add_breadcrumb(callable, None): if set, manage the breadcrumb data for this
+            page, otherwise it will be set automatically from page name or label.
+        @param prepare_render(callable, None): if set, will be used to prepare the
+            rendering. That often means gathering data using the bridge
+        @param render(callable, None): if template is not set, this method will be
+            called and what it returns will be rendered.
+            This method is mutually exclusive with template and must return a unicode
+            string.
+        @param template(unicode, None): path to the template to render.
+            This method is mutually exclusive with render
+        @param on_data_post(callable, None): method to call when data is posted
+            None if data post is not handled
+            "continue" if data post is not handled there, and we must not interrupt
+            workflow (i.e. it's handled in "render" method).
+            otherwise, on_data_post can return a string with following value:
+                - C.POST_NO_CONFIRM: confirm flag will not be set
+            on_data_post can raise following exceptions:
+                - exceptions.DataError: value is incorrect, message will be displayed
+                    as a notification
+        @param on_data(callable, None): method to call when dynamic data is sent
+            this method is used with Libervia's websocket mechanism
+        @param url_cache(boolean): if set, result of parse_url is cached (per profile).
+            Useful when costly calls (e.g. network) are done while parsing URL.
+        @param replace_on_conflict(boolean): if True, don't raise ConflictError if a
+            page of this name already exists, but replace it
+        """
+
+        web_resource.Resource.__init__(self)
+        self.host = host
+        self.vhost_root = vhost_root
+        self.root_dir = root_dir
+        self.url = url
+        self.name = name
+        self.label = label
+        self.dyn_data = {}
+        if name is not None:
+            if (name in self.named_pages
+                and not (replace_on_conflict and self.named_pages[name].url == url)):
+                raise exceptions.ConflictError(
+                    _('a Libervia page named "{}" already exists'.format(name)))
+            if "/" in name:
+                raise ValueError(_('"/" is not allowed in page names'))
+            if not name:
+                raise ValueError(_("a page name can't be empty"))
+            self.named_pages[name] = self
+        if access is None:
+            access = C.PAGES_ACCESS_PUBLIC
+        if access not in (
+            C.PAGES_ACCESS_PUBLIC,
+            C.PAGES_ACCESS_PROFILE,
+            C.PAGES_ACCESS_NONE,
+        ):
+            raise NotImplementedError(
+                _("{} access is not implemented yet").format(access)
+            )
+        self.access = access
+        self.dynamic = dynamic
+        if redirect is not None:
+            # only page access and name make sense in case of full redirection
+            # so we check that rendering methods/values are not set
+            if not all(
+                lambda x: x is not None
+                for x in (parse_url, prepare_render, render, template)
+            ):
+                raise ValueError(
+                    _("you can't use full page redirection with other rendering"
+                      "method, check self.page_redirect if you need to use them"))
+            self.redirect = redirect
+        else:
+            self.redirect = None
+        self.parse_url = parse_url
+        self.add_breadcrumb = add_breadcrumb
+        self.prepare_render = prepare_render
+        self.template = template
+        self.render_method = render
+        self.on_data_post = on_data_post
+        self.on_data = on_data
+        self.url_cache = url_cache
+        if access == C.PAGES_ACCESS_NONE:
+            # none pages just return a 404, no further check is needed
+            return
+        if template is not None and render is not None:
+            log.error(_("render and template methods can't be used at the same time"))
+
+        # if not None, next rendering will be cached
+        #  it must then contain a list of the the keys to use (without the page instance)
+        # e.g. [C.SERVICE_PROFILE, "pubsub", server@example.tld, pubsub_node]
+        self._do_cache = None
+
+    def __str__(self):
+        return "LiberviaPage {name} at {url} (vhost: {vhost_root})".format(
+            name=self.name or "<anonymous>", url=self.url, vhost_root=self.vhost_root)
+
+    @property
+    def named_pages(self):
+        return self.vhost_root.named_pages
+
+    @property
+    def uri_callbacks(self):
+        return self.vhost_root.uri_callbacks
+
+    @property
+    def pages_redirects(self):
+        return self.vhost_root.pages_redirects
+
+    @property
+    def cached_urls(self):
+        return self.vhost_root.cached_urls
+
+    @property
+    def main_menu(self):
+        return self.vhost_root.main_menu
+
+    @property
+    def default_theme(self):
+        return self.vhost_root.default_theme
+
+
+    @property
+    def site_themes(self):
+        return self.vhost_root.site_themes
+
+    @staticmethod
+    def create_page(host, meta_path, vhost_root, url_elts, replace_on_conflict=False):
+        """Create a LiberviaPage instance
+
+        @param meta_path(Path): path to the page_meta.py file
+        @param vhost_root(resource.Resource): root resource of the virtual host
+        @param url_elts(list[unicode]): list of path element from root site to this page
+        @param replace_on_conflict(bool): same as for [LiberviaPage]
+        @return (tuple[dict, LiberviaPage]): tuple with:
+            - page_data: dict containing data of the page
+            - libervia_page: created resource
+        """
+        dir_path = meta_path.parent
+        page_data = {"__name__": ".".join(["page"] + url_elts)}
+        # we don't want to force the presence of __init__.py
+        # so we use execfile instead of import.
+        # TODO: when moved to Python 3, __init__.py is not mandatory anymore
+        #       so we can switch to import
+        exec(compile(open(meta_path, "rb").read(), meta_path, 'exec'), page_data)
+        return page_data, LiberviaPage(
+            host=host,
+            vhost_root=vhost_root,
+            root_dir=dir_path,
+            url="/" + "/".join(url_elts),
+            name=page_data.get("name"),
+            label=page_data.get("label"),
+            redirect=page_data.get("redirect"),
+            access=page_data.get("access"),
+            dynamic=page_data.get("dynamic", True),
+            parse_url=page_data.get("parse_url"),
+            add_breadcrumb=page_data.get("add_breadcrumb"),
+            prepare_render=page_data.get("prepare_render"),
+            render=page_data.get("render"),
+            template=page_data.get("template"),
+            on_data_post=page_data.get("on_data_post"),
+            on_data=page_data.get("on_data"),
+            url_cache=page_data.get("url_cache", False),
+            replace_on_conflict=replace_on_conflict
+        )
+
+    @staticmethod
+    def create_browser_data(
+        vhost_root,
+        resource: Optional[LiberviaPage],
+        browser_path: Path,
+        path_elts: Optional[List[str]],
+        engine: str = "brython"
+    ) -> None:
+        """create and store data for browser dynamic code"""
+        dyn_data = {
+            "path": browser_path,
+            "url_hash": (
+                hashlib.sha256('/'.join(path_elts).encode()).hexdigest()
+                if path_elts is not None else None
+            ),
+        }
+        browser_meta_path = browser_path / C.PAGES_BROWSER_META_FILE
+        if browser_meta_path.is_file():
+            with browser_meta_path.open() as f:
+                browser_meta = json.load(f)
+            utils.recursive_update(vhost_root.browser_modules, browser_meta)
+            if resource is not None:
+                utils.recursive_update(resource.dyn_data, browser_meta)
+
+        init_path = browser_path / '__init__.py'
+        if init_path.is_file():
+            vhost_root.browser_modules.setdefault(
+                engine, []).append(dyn_data)
+            if resource is not None:
+                resource.dyn_data[engine] = dyn_data
+        elif path_elts is None:
+            try:
+                next(browser_path.glob('*.py'))
+            except StopIteration:
+                # no python file, nothing for Brython
+                pass
+            else:
+                vhost_root.browser_modules.setdefault(
+                    engine, []).append(dyn_data)
+
+
+    @classmethod
+    def import_pages(cls, host, vhost_root, root_path=None, _parent=None, _path=None,
+        _extra_pages=False):
+        """Recursively import Libervia pages
+
+        @param host(Libervia): Libervia instance
+        @param vhost_root(LiberviaRootResource): root of this VirtualHost
+        @param root_path(Path, None): use this root path instead of vhost_root's one
+            Used to add default site pages to external sites
+        @param _parent(Resource, None): _parent page. Do not set yourself, this is for
+            internal use only
+        @param _path(list(unicode), None): current path. Do not set yourself, this is for
+            internal use only
+        @param _extra_pages(boolean): set to True when extra pages are used (i.e.
+            root_path is set). Do not set yourself, this is for internal use only
+        """
+        if _path is None:
+            _path = []
+        if _parent is None:
+            if root_path is None:
+                root_dir = vhost_root.site_path / C.PAGES_DIR
+            else:
+                root_dir = root_path / C.PAGES_DIR
+                _extra_pages = True
+            _parent = vhost_root
+            root_browser_path = root_dir / C.PAGES_BROWSER_DIR
+            if root_browser_path.is_dir():
+                cls.create_browser_data(vhost_root, None, root_browser_path, None)
+        else:
+            root_dir = _parent.root_dir
+
+        for d in os.listdir(root_dir):
+            dir_path = root_dir / d
+            if not dir_path.is_dir():
+                continue
+            if _extra_pages and d in _parent.children:
+                log.debug(_("[{host_name}] {path} is already present, ignoring it")
+                    .format(host_name=vhost_root.host_name, path='/'.join(_path+[d])))
+                continue
+            meta_path = dir_path / C.PAGES_META_FILE
+            if meta_path.is_file():
+                new_path = _path + [d]
+                try:
+                    page_data, resource = cls.create_page(
+                        host, meta_path, vhost_root, new_path)
+                except exceptions.ConflictError as e:
+                    if _extra_pages:
+                        # extra pages are discarded if there is already an existing page
+                        continue
+                    else:
+                        raise e
+                _parent.putChild(str(d).encode(), resource)
+                log_msg = ("[{host_name}] Added /{path} page".format(
+                    host_name=vhost_root.host_name,
+                    path="[…]/".join(new_path)))
+                if _extra_pages:
+                    log.debug(log_msg)
+                else:
+                    log.info(log_msg)
+                if "uri_handlers" in page_data:
+                    if not isinstance(page_data, dict):
+                        log.error(_("uri_handlers must be a dict"))
+                    else:
+                        for uri_tuple, cb_name in page_data["uri_handlers"].items():
+                            if len(uri_tuple) != 2 or not isinstance(cb_name, str):
+                                log.error(_("invalid uri_tuple"))
+                                continue
+                            if not _extra_pages:
+                                log.info(_("setting {}/{} URIs handler")
+                                         .format(*uri_tuple))
+                            try:
+                                cb = page_data[cb_name]
+                            except KeyError:
+                                log.error(_("missing {name} method to handle {1}/{2}")
+                                          .format(name=cb_name, *uri_tuple))
+                                continue
+                            else:
+                                resource.register_uri(uri_tuple, cb)
+
+                LiberviaPage.import_pages(
+                    host, vhost_root, _parent=resource, _path=new_path,
+                    _extra_pages=_extra_pages)
+                # now we check if there is some code for browser
+                browser_path = dir_path / C.PAGES_BROWSER_DIR
+                if browser_path.is_dir():
+                    cls.create_browser_data(vhost_root, resource, browser_path, new_path)
+
+    @classmethod
+    def on_file_change(
+        cls,
+        host,
+        file_path: FilePath,
+        flags: List[str],
+        site_root: LiberviaRootResource,
+        site_path: Path
+    ) -> None:
+        """Method triggered by file_watcher when something is changed in files
+
+        This method is used in dev mode to reload pages when needed
+        @param file_path: path of the file which triggered the event
+        @param flags: human readable flags of the event (from
+            internet.inotify)
+        @param site_root: root of the site
+        @param site_path: absolute path of the site
+        """
+        if flags == ['create']:
+            return
+        path = Path(file_path.path.decode())
+        base_name = path.name
+        if base_name != "page_meta.py":
+            # we only handle libervia pages
+            return
+
+        log.debug("{flags} event(s) received for {file_path}".format(
+            flags=", ".join(flags), file_path=file_path))
+
+        dir_path = path.parent
+
+        if dir_path == site_path:
+            return
+
+        if not site_path in dir_path.parents:
+            raise exceptions.InternalError("watched file should be in a subdirectory of site path")
+
+        path_elts = list(dir_path.relative_to(site_path).parts)
+
+        if path_elts[0] == C.PAGES_DIR:
+            # a page has been modified
+            del path_elts[0]
+            if not path_elts:
+                # we need at least one element to parse
+                return
+            # we retrieve page by starting from site root and finding each path element
+            parent = page = site_root
+            new_page = False
+            for idx, child_name in enumerate(path_elts):
+                child_name = child_name.encode()
+                try:
+                    try:
+                        page = page.original.children[child_name]
+                    except AttributeError:
+                        page = page.children[child_name]
+                except KeyError:
+                    if idx != len(path_elts)-1:
+                        # a page has been created in a subdir when one or more
+                        # page_meta.py are missing on the way
+                        log.warning(_("Can't create a page at {path}, missing parents")
+                                    .format(path=path))
+                        return
+                    new_page = True
+                else:
+                    if idx<len(path_elts)-1:
+                        try:
+                            parent = page.original
+                        except AttributeError:
+                            parent = page
+
+            try:
+                # we (re)create a page with the new/modified code
+                __, resource = cls.create_page(host, path, site_root, path_elts,
+                                              replace_on_conflict=True)
+                if not new_page:
+                    try:
+                        resource.children = page.original.children
+                    except AttributeError:
+                        # FIXME: this .original handling madness is due to EncodingResourceWrapper
+                        #        EncodingResourceWrapper should probably be removed
+                        resource.children = page.children
+            except Exception as e:
+                log.warning(_("Can't create page: {reason}").format(reason=e))
+            else:
+                url_elt = path_elts[-1].encode()
+                if not new_page:
+                    # the page was already existing, we remove it
+                    del parent.children[url_elt]
+                # we can now add the new page
+                parent.putChild(url_elt, resource)
+
+                # is there any browser data to create?
+                browser_path = resource.root_dir / C.PAGES_BROWSER_DIR
+                if browser_path.is_dir():
+                    cls.create_browser_data(
+                        resource.vhost_root,
+                        resource,
+                        browser_path,
+                        resource.url.split('/')
+                    )
+
+                if new_page:
+                    log.info(_("{page} created").format(page=resource))
+                else:
+                    log.info(_("{page} reloaded").format(page=resource))
+
+    def check_csrf(self, request):
+        session = self.host.get_session_data(
+            request, session_iface.IWebSession
+        )
+        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:
+                given_csrf = self.get_posted_data(request, "csrf_token")
+            except KeyError:
+                pass
+        if given_csrf is None or given_csrf != csrf_token:
+            log.warning(
+                _("invalid CSRF token, hack attempt? URL: {url}, IP: {ip}").format(
+                    url=request.uri, ip=request.getClientIP()
+                )
+            )
+            self.page_error(request, C.HTTP_FORBIDDEN)
+
+    def expose_to_scripts(
+        self,
+        request: server.Request,
+        **kwargs: str
+    ) -> None:
+        """Make a local variable available to page script as a global variable
+
+        No check is done for conflicting name, use this carefully
+        """
+        template_data = request.template_data
+        scripts = template_data.setdefault("scripts", utils.OrderedSet())
+        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};"))
+
+    def register_uri(self, uri_tuple, get_uri_cb):
+        """Register a URI handler
+
+        @param uri_tuple(tuple[unicode, unicode]): type or URIs handler
+            type/subtype as returned by tools/common/parse_xmpp_uri
+            or type/None to handle all subtypes
+        @param get_uri_cb(callable): method which take uri_data dict as only argument
+            and return absolute path with correct arguments or None if the page
+            can't handle this URL
+        """
+        if uri_tuple in self.uri_callbacks:
+            log.info(_("{}/{} URIs are already handled, replacing by the new handler")
+                .format( *uri_tuple))
+        self.uri_callbacks[uri_tuple] = (self, get_uri_cb)
+
+    def config_get(self, key, default=None, value_type=None):
+        return self.host.config_get(self.vhost_root, key=key, default=default,
+                                   value_type=value_type)
+
+    def get_build_path(self, session_data):
+        return session_data.cache_dir + self.vhost.site_name
+
+    def get_page_by_name(self, name):
+        return self.vhost_root.get_page_by_name(name)
+
+    def get_page_path_from_uri(self, uri):
+        return self.vhost_root.get_page_path_from_uri(uri)
+
+    def get_page_redirect_url(self, request, page_name="login", url=None):
+        """generate URL for a page with redirect_url parameter set
+
+        mainly used for login page with redirection to current page
+        @param request(server.Request): current HTTP request
+        @param page_name(unicode): name of the page to go
+        @param url(None, unicode): url to redirect to
+            None to use request path (i.e. current page)
+        @return (unicode): URL to use
+        """
+        return "{root_url}?redirect_url={redirect_url}".format(
+            root_url=self.get_page_by_name(page_name).url,
+            redirect_url=urllib.parse.quote_plus(request.uri)
+            if url is None
+            else url.encode("utf-8"),
+        )
+
+    def get_url(self, *args: str, **kwargs: str) -> str:
+        """retrieve URL of the page set arguments
+
+        @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]
+
+        if self.name is not None and self.name in self.pages_redirects:
+            #  we check for redirection
+            redirect_data = self.pages_redirects[self.name]
+            args_hash = tuple(args)
+            for limit in 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)
+                    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)
+
+        if kwargs:
+            encoded = urllib.parse.urlencode(
+                {k: v for k, v in kwargs.items()}
+            )
+            url +=  f"?{encoded}"
+
+        return self.host.check_redirection(
+            self.vhost_root,
+            url
+        )
+
+    def get_current_url(self, request):
+        """retrieve URL used to access this page
+
+        @return(unicode): current URL
+        """
+        # we get url in the following way (splitting request.path instead of using
+        # request.prepath) because request.prepath may have been modified by
+        # redirection (if redirection args have been specified), while path reflect
+        # the real request
+
+        # we ignore empty path elements (i.e. double '/' or '/' at the end)
+        path_elts = [p for p in request.path.decode('utf-8').split("/") if p]
+
+        if request.postpath:
+            if not request.postpath[-1]:
+                #  we remove trailing slash
+                request.postpath = request.postpath[:-1]
+            if request.postpath:
+                #  get_sub_page_url must return subpage from the point where
+                # the it is called, so we have to remove remanining
+                # path elements
+                path_elts = path_elts[: -len(request.postpath)]
+
+        return "/" + "/".join(path_elts)
+
+    def get_param_url(self, request, **kwargs):
+        """use URL of current request but modify the parameters in query part
+
+        **kwargs(dict[str, unicode]): argument to use as query parameters
+        @return (unicode): constructed URL
+        """
+        current_url = self.get_current_url(request)
+        if kwargs:
+            encoded = urllib.parse.urlencode(
+                {k: v for k, v in kwargs.items()}
+            )
+            current_url = current_url + "?" + encoded
+        return current_url
+
+    def get_sub_page_by_name(self, subpage_name, parent=None):
+        """retrieve a subpage and its path using its name
+
+        @param subpage_name(unicode): name of the sub page
+            it must be a direct children of parent page
+        @param parent(LiberviaPage, None): parent page
+            None to use current page
+        @return (tuple[str, LiberviaPage]): page subpath and instance
+        @raise exceptions.NotFound: no page has been found
+        """
+        if parent is None:
+            parent = self
+        for path, child in parent.children.items():
+            try:
+                child_name = child.name
+            except AttributeError:
+                #  LiberviaPages have a name, but maybe this is an other Resource
+                continue
+            if child_name == subpage_name:
+                return path.decode('utf-8'), child
+        raise exceptions.NotFound(
+            _("requested sub page has not been found ({subpage_name})").format(
+            subpage_name=subpage_name))
+
+    def get_sub_page_url(self, request, page_name, *args):
+        """retrieve a page in direct children and build its URL according to request
+
+        request's current path is used as base (at current parsing point,
+        i.e. it's more prepath than path).
+        Requested page is checked in children and an absolute URL is then built
+        by the resulting combination.
+        This method is useful to construct absolute URLs for children instead of
+        using relative path, which may not work in subpages, and are linked to the
+        names of directories (i.e. relative URL will break if subdirectory is renamed
+        while get_sub_page_url won't as long as page_name is consistent).
+        Also, request.path is used, keeping real path used by user,
+        and potential redirections.
+        @param request(server.Request): current HTTP request
+        @param page_name(unicode): name of the page to retrieve
+            it must be a direct children of current page
+        @param *args(list[unicode]): arguments to add as path elements
+            if an arg is None, it will be ignored
+        @return (unicode): absolute URL to the sub page
+        """
+        current_url = self.get_current_url(request)
+        path, child = self.get_sub_page_by_name(page_name)
+        return os.path.join(
+            "/", current_url, path, *[quote(a) for a in args if a is not None]
+        )
+
+    def get_url_by_names(self, named_path):
+        """Retrieve URL from pages names and arguments
+
+        @param named_path(list[tuple[unicode, list[unicode]]]): path to the page as a list
+            of tuples of 2 items:
+                - first item is page name
+                - second item is list of path arguments of this page
+        @return (unicode): URL to the requested page with given path arguments
+        @raise exceptions.NotFound: one of the page was not found
+        """
+        current_page = None
+        path = []
+        for page_name, page_args in named_path:
+            if current_page is None:
+                current_page = self.get_page_by_name(page_name)
+                path.append(current_page.get_url(*page_args))
+            else:
+                sub_path, current_page = self.get_sub_page_by_name(
+                    page_name, parent=current_page
+                )
+                path.append(sub_path)
+                if page_args:
+                    path.extend([quote(a) for a in page_args])
+        return self.host.check_redirection(self.vhost_root, "/".join(path))
+
+    def get_url_by_path(self, *args):
+        """Generate URL by path
+
+        this method as a similar effect as get_url_by_names, but it is more readable
+        by using SubPage to get pages instead of using tuples
+        @param *args: path element:
+            - if unicode, will be used as argument
+            - if util.SubPage instance, must be the name of a subpage
+        @return (unicode): generated path
+        """
+        args = list(args)
+        if not args:
+            raise ValueError("You must specify path elements")
+        # root page is the one needed to construct the base of the URL
+        # if first arg is not a SubPage instance, we use current page
+        if not isinstance(args[0], SubPage):
+            root = self
+        else:
+            root = self.get_page_by_name(args.pop(0))
+        # we keep track of current page to check subpage
+        current_page = root
+        url_elts = []
+        arguments = []
+        while True:
+            while args and not isinstance(args[0], SubPage):
+                arguments.append(quote(args.pop(0)))
+            if not url_elts:
+                url_elts.append(root.get_url(*arguments))
+            else:
+                url_elts.extend(arguments)
+            if not args:
+                break
+            else:
+                path, current_page = current_page.get_sub_page_by_name(args.pop(0))
+                arguments = [path]
+        return self.host.check_redirection(self.vhost_root, "/".join(url_elts))
+
+    def getChildWithDefault(self, path, request):
+        # we handle children ourselves
+        raise exceptions.InternalError(
+            "this method should not be used with LiberviaPage"
+        )
+
+    def next_path(self, request):
+        """get next URL path segment, and update request accordingly
+
+        will move first segment of postpath in prepath
+        @param request(server.Request): current HTTP request
+        @return (unicode): unquoted segment
+        @raise IndexError: there is no segment left
+        """
+        pathElement = request.postpath.pop(0)
+        request.prepath.append(pathElement)
+        return urllib.parse.unquote(pathElement.decode('utf-8'))
+
+    def _filter_path_value(self, value, handler, name, request):
+        """Modify a path value according to handler (see [get_path_args])"""
+        if handler in ("@", "@jid") and value == "@":
+            value = None
+
+        if handler in ("", "@"):
+            if value is None:
+                return ""
+        elif handler in ("jid", "@jid"):
+            if value:
+                try:
+                    return jid.JID(value)
+                except (RuntimeError, jid.InvalidFormat):
+                    log.warning(_("invalid jid argument: {value}").format(value=value))
+                    self.page_error(request, C.HTTP_BAD_REQUEST)
+            else:
+                return ""
+        else:
+            return handler(self, value, name, request)
+
+        return value
+
+    def get_path_args(self, request, names, min_args=0, **kwargs):
+        """get several path arguments at once
+
+        Arguments will be put in request data.
+        Missing arguments will have None value
+        @param names(list[unicode]): list of arguments to get
+        @param min_args(int): if less than min_args are found, PageError is used with
+            C.HTTP_BAD_REQUEST
+            Use 0 to ignore
+        @param **kwargs: special value or optional callback to use for arguments
+            names of the arguments must correspond to those in names
+            special values may be:
+                - '': use empty string instead of None when no value is specified
+                - '@': if value of argument is empty or '@', empty string will be used
+                - 'jid': value must be converted to jid.JID if it exists, else empty
+                    string is used
+                - '@jid': if value of arguments is empty or '@', empty string will be
+                    used, else it will be converted to jid
+        """
+        data = self.get_r_data(request)
+
+        for idx, name in enumerate(names):
+            if name[0] == "*":
+                value = data[name[1:]] = []
+                while True:
+                    try:
+                        value.append(self.next_path(request))
+                    except IndexError:
+                        idx -= 1
+                        break
+                    else:
+                        idx += 1
+            else:
+                try:
+                    value = data[name] = self.next_path(request)
+                except IndexError:
+                    data[name] = None
+                    idx -= 1
+                    break
+
+        values_count = idx + 1
+        if values_count < min_args:
+            log.warning(_("Missing arguments in URL (got {count}, expected at least "
+                          "{min_args})").format(count=values_count, min_args=min_args))
+            self.page_error(request, C.HTTP_BAD_REQUEST)
+
+        for name in names[values_count:]:
+            data[name] = None
+
+        for name, handler in kwargs.items():
+            if name[0] == "*":
+                data[name] = [
+                    self._filter_path_value(v, handler, name, request) for v in data[name]
+                ]
+            else:
+                data[name] = self._filter_path_value(data[name], handler, name, request)
+
+    ## Pagination/Filtering ##
+
+    def get_pubsub_extra(self, request, page_max=10, params=None, extra=None,
+        order_by=C.ORDER_BY_CREATION):
+        """Set extra dict to retrieve PubSub items corresponding to URL parameters
+
+        Following parameters are used:
+            - after: set rsm_after with ID of item
+            - before: set rsm_before with ID of item
+        @param request(server.Request): current HTTP request
+        @param page_max(int): required number of items per page
+        @param params(None, dict[unicode, list[unicode]]): params as returned by
+            self.get_all_posted_data.
+            None to parse URL automatically
+        @param extra(None, dict): extra dict to use, or None to use a new one
+        @param order_by(unicode, None): key to order by
+            None to not specify order
+        @return (dict): fill extra data
+        """
+        if params is None:
+            params = self.get_all_posted_data(request, multiple=False)
+        if extra is None:
+            extra = {}
+        else:
+            assert not {"rsm_max", "rsm_after", "rsm_before",
+                        C.KEY_ORDER_BY}.intersection(list(extra.keys()))
+        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:
+            extra['rsm_after'] = params['after']
+        elif 'before' in params:
+            extra['rsm_before'] = params['before']
+        else:
+            # RSM returns list in order (oldest first), but we want most recent first
+            # so we start by the end
+            extra['rsm_before'] = ""
+        return extra
+
+    def set_pagination(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: current HTTP request
+        @param pubsub_data: pubsub metadata
+        """
+        template_data = request.template_data
+        extra = {}
+        try:
+            rsm = pubsub_data["rsm"]
+            last_id = rsm["last"]
+        except KeyError:
+            # no pagination available
+            return
+
+        # if we have a search query, we must keep it
+        search = self.get_posted_data(request, 'search', raise_on_missing=False)
+        if search is not None:
+            extra['search'] = search.strip()
+
+        # same for page_max
+        page_max = self.get_posted_data(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
+            # as we can't know if we are on the first page or not.
+            first_id = rsm["first"]
+            template_data['previous_page_url'] = self.get_param_url(
+                request, before=first_id, **extra)
+        if not pubsub_data["complete"]:
+            # we also show the page next button if complete is None because we
+            # can't know where we are in the feed in this case.
+            template_data['next_page_url'] = self.get_param_url(
+                request, after=last_id, **extra)
+
+
+    ## Cache handling ##
+
+    def _set_cache_headers(self, request, cache):
+        """Set ETag and Last-Modified HTTP headers, used for caching"""
+        request.setHeader("ETag", cache.hash)
+        last_modified = self.host.get_http_date(cache.created)
+        request.setHeader("Last-Modified", last_modified)
+
+    def _check_cache_headers(self, request, cache):
+        """Check if a cache condition is set on the request
+
+        if condition is valid, C.HTTP_NOT_MODIFIED is returned
+        """
+        etag_match = request.getHeader("If-None-Match")
+        if etag_match is not None:
+            if cache.hash == etag_match:
+                self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True)
+        else:
+            modified_match = request.getHeader("If-Modified-Since")
+            if modified_match is not None:
+                modified = date_utils.date_parse(modified_match)
+                if modified >= int(cache.created):
+                    self.page_error(request, C.HTTP_NOT_MODIFIED, no_body=True)
+
+    def check_cache_subscribe_cb(self, sub_id, service, node):
+        self.cache_pubsub_sub.add((service, node, sub_id))
+
+    def check_cache_subscribe_eb(self, failure_, service, node):
+        log.warning(_("Can't subscribe to node: {msg}").format(msg=failure_))
+        # FIXME: cache must be marked as unusable here
+
+    def ps_node_watch_add_eb(self, failure_, service, node):
+        log.warning(_("Can't add node watched: {msg}").format(msg=failure_))
+
+    def use_cache(self, request: server.Request) -> bool:
+        """Indicate if the cache should be used
+
+        test request header to see if it is requested to skip the cache
+        @return: True if cache should be used
+        """
+        return request.getHeader('cache-control') != 'no-cache'
+
+    def check_cache(self, request, cache_type, **kwargs):
+        """check if a page is in cache and return cached version if suitable
+
+        this method may perform extra operation to handle cache (e.g. subscribing to a
+        pubsub node)
+        @param request(server.Request): current HTTP request
+        @param cache_type(int): on of C.CACHE_* const.
+        @param **kwargs: args according to cache_type:
+            C.CACHE_PUBSUB:
+                service: pubsub service
+                node: pubsub node
+                short: short name of feature (needed if node is empty to find namespace)
+
+        """
+        if request.postpath:
+            #  we are not on the final page, no need to go further
+            return
+
+        if request.uri != request.path:
+            # we don't cache page with query arguments as there can be a lot of variants
+            # influencing page results (e.g. search terms)
+            log.debug("ignoring cache due to query arguments")
+
+        no_cache = not self.use_cache(request)
+
+        profile = self.get_profile(request) or C.SERVICE_PROFILE
+
+        if cache_type == C.CACHE_PUBSUB:
+            service, node = kwargs["service"], kwargs["node"]
+            if not node:
+                try:
+                    short = kwargs["short"]
+                    node = self.host.ns_map[short]
+                except KeyError:
+                    log.warning(_('Can\'t use cache for empty node without namespace '
+                                  'set, please ensure to set "short" and that it is '
+                                  'registered'))
+                    return
+            if profile != C.SERVICE_PROFILE:
+                #  only service profile is cached for now
+                return
+            session_data = self.host.get_session_data(request, session_iface.IWebSession)
+            locale = session_data.locale
+            if locale == C.DEFAULT_LOCALE:
+                # no need to duplicate cache here
+                locale = None
+            try:
+                cache = (self.cache[profile][cache_type][service][node]
+                         [self.vhost_root][request.uri][locale][self])
+            except KeyError:
+                # no cache yet, let's subscribe to the pubsub node
+                d1 = self.host.bridge_call(
+                    "ps_subscribe", service.full(), node, "", profile
+                )
+                d1.addCallback(self.check_cache_subscribe_cb, service, node)
+                d1.addErrback(self.check_cache_subscribe_eb, service, node)
+                d2 = self.host.bridge_call("ps_node_watch_add", service.full(), node, profile)
+                d2.addErrback(self.ps_node_watch_add_eb, service, node)
+                self._do_cache = [self, profile, cache_type, service, node,
+                                  self.vhost_root, request.uri, locale]
+                #  we don't return the Deferreds as it is not needed to wait for
+                # the subscription to continue with page rendering
+                return
+            else:
+                if no_cache:
+                    del (self.cache[profile][cache_type][service][node]
+                         [self.vhost_root][request.uri][locale][self])
+                    log.debug(f"cache removed for {self}")
+                    return
+
+        else:
+            raise exceptions.InternalError("Unknown cache_type")
+        log.debug("using cache for {page}".format(page=self))
+        cache.last_access = time.time()
+        self._set_cache_headers(request, cache)
+        self._check_cache_headers(request, cache)
+        request.write(cache.rendered)
+        request.finish()
+        raise failure.Failure(exceptions.CancelError("cache is used"))
+
+    def _cache_url(self, request, profile):
+        self.cached_urls.setdefault(profile, {})[request.uri] = CacheURL(request)
+
+    @classmethod
+    def on_node_event(cls, host, service, node, event_type, items, profile):
+        """Invalidate cache for all pages linked to this node"""
+        try:
+            cache = cls.cache[profile][C.CACHE_PUBSUB][jid.JID(service)][node]
+        except KeyError:
+            log.info(_(
+                "Removing subscription for {service}/{node}: "
+                "the page is not cached").format(service=service, node=node))
+            d1 = host.bridge_call("ps_unsubscribe", service, node, profile)
+            d1.addErrback(
+                lambda failure_: log.warning(
+                    _("Can't unsubscribe from {service}/{node}: {msg}").format(
+                        service=service, node=node, msg=failure_)))
+            d2 = host.bridge_call("ps_node_watch_add", service, node, profile)
+            # TODO: check why the page is not in cache, remove subscription?
+            d2.addErrback(
+                lambda failure_: log.warning(
+                    _("Can't remove watch for {service}/{node}: {msg}").format(
+                        service=service, node=node, msg=failure_)))
+        else:
+            cache.clear()
+
+    # identities
+
+    async def fill_missing_identities(
+        self,
+        request: server.Request,
+        entities: List[Union[str, jid.JID, None]],
+    ) -> None:
+        """Check if all entities have an identity cache, get missing ones from backend
+
+        @param request: request with a plugged profile
+        @param entities: entities to check, None or empty strings will be filtered
+        """
+        entities = {str(e) for e in entities if e}
+        profile = self.get_profile(request) or C.SERVICE_PROFILE
+        identities = self.host.get_session_data(
+            request,
+            session_iface.IWebSession
+        ).identities
+        for e in entities:
+            if e not in identities:
+                id_raw = await self.host.bridge_call(
+                    'identity_get', e, [], True, profile)
+                identities[e] = data_format.deserialise(id_raw)
+
+    # signals, server => browser communication
+
+    def delegate_to_resource(self, request, resource):
+        """continue workflow with Twisted Resource"""
+        buf = resource.render(request)
+        if buf == server.NOT_DONE_YET:
+            pass
+        else:
+            request.write(buf)
+            request.finish()
+        raise failure.Failure(exceptions.CancelError("resource delegation"))
+
+    def http_redirect(self, request, url):
+        """redirect to an URL using HTTP redirection
+
+        @param request(server.Request): current HTTP request
+        @param url(unicode): url to redirect to
+        """
+        web_util.redirectTo(url.encode("utf-8"), request)
+        request.finish()
+        raise failure.Failure(exceptions.CancelError("HTTP redirection is used"))
+
+    def redirect_or_continue(self, request, redirect_arg="redirect_url"):
+        """Helper method to redirect a page to an url given as arg
+
+        if the arg is not present, the page will continue normal workflow
+        @param request(server.Request): current HTTP request
+        @param redirect_arg(unicode): argument to use to get redirection URL
+        @interrupt: redirect the page to requested URL
+        @interrupt page_error(C.HTTP_BAD_REQUEST): empty or non local URL is used
+        """
+        redirect_arg = redirect_arg.encode('utf-8')
+        try:
+            url = request.args[redirect_arg][0].decode('utf-8')
+        except (KeyError, IndexError):
+            pass
+        else:
+            #  a redirection is requested
+            if not url or url[0] != "/":
+                # we only want local urls
+                self.page_error(request, C.HTTP_BAD_REQUEST)
+            else:
+                self.http_redirect(request, url)
+
+    def page_redirect(self, page_path, request, skip_parse_url=True, path_args=None):
+        """redirect a page to a named page
+
+        the workflow will continue with the workflow of the named page,
+        skipping named page's parse_url method if it exist.
+        If you want to do a HTTP redirection, use http_redirect
+        @param page_path(unicode): path to page (elements are separated by "/"):
+            if path starts with a "/":
+                path is a full path starting from root
+            else:
+                - first element is name as registered in name variable
+                - following element are subpages path
+            e.g.: "blog" redirect to page named "blog"
+                  "blog/atom.xml" redirect to atom.xml subpage of "blog"
+                  "/common/blog/atom.xml" redirect to the page at the given full path
+        @param request(server.Request): current HTTP request
+        @param skip_parse_url(bool): if True, parse_url method on redirect page will be
+            skipped
+        @param path_args(list[unicode], None): path arguments to use in redirected page
+        @raise KeyError: there is no known page with this name
+        """
+        # FIXME: render non LiberviaPage resources
+        path = page_path.rstrip("/").split("/")
+        if not path[0]:
+            redirect_page = self.vhost_root
+        else:
+            redirect_page = self.named_pages[path[0]]
+
+        for subpage in path[1:]:
+            subpage = subpage.encode('utf-8')
+            if redirect_page is self.vhost_root:
+                redirect_page = redirect_page.children[subpage]
+            else:
+                redirect_page = redirect_page.original.children[subpage]
+
+        if path_args is not None:
+            args = [quote(a).encode() for a in path_args]
+            request.postpath = args + request.postpath
+
+        if self._do_cache:
+            # if cache is needed, it will be handled by final page
+            redirect_page._do_cache = self._do_cache
+            self._do_cache = None
+
+        defer.ensureDeferred(
+            redirect_page.render_page(request, skip_parse_url=skip_parse_url)
+        )
+        raise failure.Failure(exceptions.CancelError("page redirection is used"))
+
+    def page_error(self, request, code=C.HTTP_NOT_FOUND, no_body=False):
+        """generate an error page and terminate the request
+
+        @param request(server.Request): HTTP request
+        @param core(int): error code to use
+        @param no_body: don't write body if True
+        """
+        if self._do_cache is not None:
+            # we don't want to cache error pages
+            self._do_cache = None
+        request.setResponseCode(code)
+        if no_body:
+            request.finish()
+        else:
+            template = "error/" + str(code) + ".html"
+            template_data = request.template_data
+            session_data = self.host.get_session_data(request, session_iface.IWebSession)
+            if session_data.locale is not None:
+                template_data['locale'] = session_data.locale
+            if self.vhost_root.site_name:
+                template_data['site'] = self.vhost_root.site_name
+
+            rendered = self.host.renderer.render(
+                template,
+                theme=session_data.theme or self.default_theme,
+                media_path=f"/{C.MEDIA_DIR}",
+                build_path=f"/{C.BUILD_DIR}/",
+                site_themes=self.site_themes,
+                error_code=code,
+                **template_data
+            )
+
+            self.write_data(rendered, request)
+        raise failure.Failure(exceptions.CancelError("error page is used"))
+
+    def write_data(self, data, request):
+        """write data to transport and finish the request"""
+        if data is None:
+            self.page_error(request)
+        data_encoded = data.encode("utf-8")
+
+        if self._do_cache is not None:
+            redirected_page = self._do_cache.pop(0)
+            cache = reduce(lambda d, k: d.setdefault(k, {}), self._do_cache, self.cache)
+            page_cache = cache[redirected_page] = CachePage(data_encoded)
+            self._set_cache_headers(request, page_cache)
+            log.debug(_("{page} put in cache for [{profile}]")
+                .format( page=self, profile=self._do_cache[0]))
+            self._do_cache = None
+            self._check_cache_headers(request, page_cache)
+
+        try:
+            request.write(data_encoded)
+        except AttributeError:
+            log.warning(_("Can't write page, the request has probably been cancelled "
+                          "(browser tab closed or reloaded)"))
+            return
+        request.finish()
+
+    def _subpages_handler(self, request):
+        """render subpage if suitable
+
+        this method checks if there is still an unmanaged part of the path
+        and check if it corresponds to a subpage. If so, it render the subpage
+        else it render a NoResource.
+        If there is no unmanaged part of the segment, current page workflow is pursued
+        """
+        if request.postpath:
+            subpage = self.next_path(request).encode('utf-8')
+            try:
+                child = self.children[subpage]
+            except KeyError:
+                self.page_error(request)
+            else:
+                child.render(request)
+                raise failure.Failure(exceptions.CancelError("subpage page is used"))
+
+    def _prepare_dynamic(self, request):
+        session_data = self.host.get_session_data(request, session_iface.IWebSession)
+        # we need to activate dynamic page
+        # we set data for template, and create/register token
+        # socket_token = str(uuid.uuid4())
+        socket_url = self.host.get_websocket_url(request)
+        # as for CSRF, it is important to not let the socket token if we use the service
+        # profile, as those pages can be cached, and then the token leaked.
+        socket_token = '' if session_data.profile is None else session_data.ws_token
+        socket_debug = C.bool_const(self.host.debug)
+        request.template_data["websocket"] = WebsocketMeta(
+            socket_url, socket_token, socket_debug
+        )
+        # we will keep track of handlers to remove
+        request._signals_registered = []
+        # we will cache registered signals until socket is opened
+        request._signals_cache = []
+
+    def _render_template(self, request):
+        template_data = request.template_data
+
+        # if confirm variable is set in case of successfuly data post
+        session_data = self.host.get_session_data(request, session_iface.IWebSession)
+        template_data['identities'] = session_data.identities
+        if session_data.pop_page_flag(self, C.FLAG_CONFIRM):
+            template_data["confirm"] = True
+        notifs = session_data.pop_page_notifications(self)
+        if notifs:
+            template_data["notifications"] = notifs
+        if session_data.jid is not None:
+            template_data["own_jid"] = session_data.jid
+        if session_data.locale is not None:
+            template_data['locale'] = session_data.locale
+        if session_data.guest:
+            template_data['guest_session'] = True
+        if self.vhost_root.site_name:
+            template_data['site'] = self.vhost_root.site_name
+        if self.dyn_data:
+            for data in self.dyn_data.values():
+                try:
+                    scripts = data['scripts']
+                except KeyError:
+                    pass
+                else:
+                    template_data.setdefault('scripts', utils.OrderedSet()).update(scripts)
+                template_data.update(data.get('template', {}))
+        data_common = self.vhost_root.dyn_data_common
+        common_scripts = data_common['scripts']
+        if common_scripts:
+            template_data.setdefault('scripts', utils.OrderedSet()).update(common_scripts)
+            if "template" in data_common:
+                for key, value in data_common["template"].items():
+                    if key not in template_data:
+                        template_data[key] = value
+
+        theme = session_data.theme or self.default_theme
+        self.expose_to_scripts(
+            request,
+            cache_path=session_data.cache_dir,
+            templates_root_url=str(self.vhost_root.get_front_url(theme)),
+            profile=session_data.profile)
+
+        uri = request.uri.decode()
+        try:
+            template_data["current_page"] = next(
+                m[0] for m in self.main_menu if uri.startswith(m[1])
+            )
+        except StopIteration:
+            pass
+
+        return self.host.renderer.render(
+            self.template,
+            theme=theme,
+            site_themes=self.site_themes,
+            page_url=self.get_url(),
+            media_path=f"/{C.MEDIA_DIR}",
+            build_path=f"/{C.BUILD_DIR}/",
+            cache_path=session_data.cache_dir,
+            main_menu=self.main_menu,
+            **template_data)
+
+    def _on_data_post_redirect(self, ret, request):
+        """called when page's on_data_post has been done successfuly
+
+        This will do a Post/Redirect/Get pattern.
+        this method redirect to the same page or to request.data['post_redirect_page']
+        post_redirect_page can be either a page or a tuple with page as first item, then
+        a list of unicode arguments to append to the url.
+        if post_redirect_page is not used, initial request.uri (i.e. the same page as
+        where the data have been posted) will be used for redirection.
+        HTTP status code "See Other" (303) is used as it is the recommanded code in
+        this case.
+        @param ret(None, unicode, iterable): on_data_post return value
+            see LiberviaPage.__init__ on_data_post docstring
+        """
+        if ret is None:
+            ret = ()
+        elif isinstance(ret, str):
+            ret = (ret,)
+        else:
+            ret = tuple(ret)
+            raise NotImplementedError(
+                _("iterable in on_data_post return value is not used yet")
+            )
+        session_data = self.host.get_session_data(request, session_iface.IWebSession)
+        request_data = self.get_r_data(request)
+        if "post_redirect_page" in request_data:
+            redirect_page_data = request_data["post_redirect_page"]
+            if isinstance(redirect_page_data, tuple):
+                redirect_page = redirect_page_data[0]
+                redirect_page_args = redirect_page_data[1:]
+                redirect_uri = redirect_page.get_url(*redirect_page_args)
+            else:
+                redirect_page = redirect_page_data
+                redirect_uri = redirect_page.url
+        else:
+            redirect_page = self
+            redirect_uri = request.uri
+
+        if not C.POST_NO_CONFIRM in ret:
+            session_data.set_page_flag(redirect_page, C.FLAG_CONFIRM)
+        request.setResponseCode(C.HTTP_SEE_OTHER)
+        request.setHeader(b"location", redirect_uri)
+        request.finish()
+        raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used"))
+
+    async def _on_data_post(self, request):
+        self.check_csrf(request)
+        try:
+            ret = await as_deferred(self.on_data_post, self, request)
+        except exceptions.DataError as e:
+            # something is wrong with the posted data, we re-display the page with a
+            # warning notification
+            session_data = self.host.get_session_data(request, session_iface.IWebSession)
+            session_data.set_page_notification(self, str(e), C.LVL_WARNING)
+            request.setResponseCode(C.HTTP_SEE_OTHER)
+            request.setHeader("location", request.uri)
+            request.finish()
+            raise failure.Failure(exceptions.CancelError("Post/Redirect/Get is used"))
+        else:
+            if ret != "continue":
+                self._on_data_post_redirect(ret, request)
+
+    def get_posted_data(
+            self,
+            request: server.Request,
+            keys,
+            multiple: bool = False,
+            raise_on_missing: bool = True,
+            strip: bool = True
+    ):
+        """Get data from a POST request or from URL's query part and decode it
+
+        @param request: request linked to the session
+        @param keys(unicode, iterable[unicode]): name of the value(s) to get
+            unicode to get one value
+            iterable to get more than one
+        @param multiple: True if multiple values are possible/expected
+            if False, the first value is returned
+        @param raise_on_missing: raise KeyError on missing key if True
+            else use None for missing values
+        @param strip: if True, apply "strip()" on values
+        @return (iterator[unicode], list[iterator[unicode], unicode, list[unicode]):
+            values received for this(these) key(s)
+        @raise KeyError: one specific key has been requested, and it is missing
+        """
+        #  FIXME: request.args is already unquoting the value, it seems we are doing
+        #         double unquote
+        if isinstance(keys, str):
+            keys = [keys]
+
+        keys = [k.encode('utf-8') for k in keys]
+
+        ret = []
+        for key in keys:
+            gen = (urllib.parse.unquote(v.decode("utf-8"))
+                                        for v in request.args.get(key, []))
+            if multiple:
+                ret.append(gen.strip() if strip else gen)
+            else:
+                try:
+                    v = next(gen)
+                except StopIteration:
+                    if raise_on_missing:
+                        raise KeyError(key)
+                    else:
+                        ret.append(None)
+                else:
+                    ret.append(v.strip() if strip else v)
+
+        if len(keys) == 1:
+            return ret[0]
+        else:
+            return ret
+
+    def get_all_posted_data(self, request, except_=(), multiple=True):
+        """get all posted data
+
+        @param request(server.Request): request linked to the session
+        @param except_(iterable[unicode]): key of values to ignore
+            csrf_token will always be ignored
+        @param multiple(bool): if False, only the first values are returned
+        @return (dict[unicode, list[unicode]]): post values
+        """
+        except_ = tuple(except_) + ("csrf_token",)
+        ret = {}
+        for key, values in request.args.items():
+            key = key.decode('utf-8')
+            key = urllib.parse.unquote(key)
+            if key in except_:
+                continue
+            values = [v.decode('utf-8') for v in values]
+            if not multiple:
+                ret[key] = urllib.parse.unquote(values[0])
+            else:
+                ret[key] = [urllib.parse.unquote(v) for v in values]
+        return ret
+
+    def get_profile(self, request):
+        """Helper method to easily get current profile
+
+        @return (unicode, None): current profile
+            None if no profile session is started
+        """
+        web_session = self.host.get_session_data(request, session_iface.IWebSession)
+        return web_session.profile
+
+    def get_jid(self, request):
+        """Helper method to easily get current jid
+
+        @return: current jid
+        """
+        web_session = self.host.get_session_data(request, session_iface.IWebSession)
+        return web_session.jid
+
+
+    def get_r_data(self, request):
+        """Helper method to get request data dict
+
+        this dictionnary if for the request only, it is not saved in session
+        It is mainly used to pass data between pages/methods called during request
+        workflow
+        @return (dict): request data
+        """
+        try:
+            return request.data
+        except AttributeError:
+            request.data = {}
+            return request.data
+
+    def get_page_data(self, request, key):
+        """Helper method to retrieve reload resistant data"""
+        web_session = self.host.get_session_data(request, session_iface.IWebSession)
+        return web_session.get_page_data(self, key)
+
+    def set_page_data(self, request, key, value):
+        """Helper method to set reload resistant data"""
+        web_session = self.host.get_session_data(request, session_iface.IWebSession)
+        return web_session.set_page_data(self, key, value)
+
+    def handle_search(self, request, extra):
+        """Manage Full-Text Search
+
+        Check if "search" query argument is present, and add MAM filter for it if
+        necessary.
+        If used, the "search" variable will also be available in template data, thus
+        frontend can display some information about it.
+        """
+        search = self.get_posted_data(request, 'search', raise_on_missing=False)
+        if search is not None:
+            search = search.strip()
+            if search:
+                try:
+                    extra[f'mam_filter_{self.host.ns_map["fulltextmam"]}'] = search
+                except KeyError:
+                    log.warning(_("Full-text search is not available"))
+                else:
+                    request.template_data['search'] = search
+
+    def _check_access(self, request):
+        """Check access according to self.access
+
+        if access is not granted, show a HTTP_FORBIDDEN page_error and stop request,
+        else return data (so it can be inserted in deferred chain
+        """
+        if self.access == C.PAGES_ACCESS_PUBLIC:
+            pass
+        elif self.access == C.PAGES_ACCESS_PROFILE:
+            profile = self.get_profile(request)
+            if not profile:
+                # registration allowed, we redirect to login page
+                login_url = self.get_page_redirect_url(request)
+                self.http_redirect(request, login_url)
+
+    def set_best_locale(self, request):
+        """Guess the best locale when it is not specified explicitly by user
+
+        This method will check "accept-language" header, and set locale to first
+        matching value with available translations.
+        """
+        accept_language = request.getHeader("accept-language")
+        if not accept_language:
+            return
+        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()
+            if not lang:
+                continue
+            for a in available:
+                if a.lower().startswith(lang):
+                    session_data = self.host.get_session_data(request,
+                                                            session_iface.IWebSession)
+                    session_data.locale = a
+                    return
+
+    async def render_page(self, request, skip_parse_url=False):
+        """Main method to handle the workflow of a LiberviaPage"""
+        # template_data are the variables passed to template
+        if not hasattr(request, "template_data"):
+            # if template_data doesn't exist, it's the beginning of the request workflow
+            # so we fill essential data
+            session_data = self.host.get_session_data(request, session_iface.IWebSession)
+            profile = session_data.profile
+            request.template_data = {
+                "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": []
+            }
+
+            # XXX: here is the code which need to be executed once
+            #      at the beginning of the request hanling
+            if request.postpath and not request.postpath[-1]:
+                # we don't differenciate URLs finishing with '/' or not
+                del request.postpath[-1]
+
+            # i18n
+            key_lang = C.KEY_LANG.encode()
+            if key_lang in request.args:
+                try:
+                    locale = request.args.pop(key_lang)[0].decode()
+                except IndexError:
+                    log.warning("empty lang received")
+                else:
+                    if "/" in locale:
+                        # "/" is refused because locale may sometime be used to access
+                        # path, if localised documents are available for instance
+                        log.warning(_('illegal char found in locale ("/"), hack '
+                                      'attempt? locale={locale}').format(locale=locale))
+                        locale = None
+                    session_data.locale = locale
+
+            # if locale is not specified, we try to find one requested by browser
+            if session_data.locale is None:
+                self.set_best_locale(request)
+
+            # theme
+            key_theme = C.KEY_THEME.encode()
+            if key_theme in request.args:
+                theme = request.args.pop(key_theme)[0].decode()
+                if key_theme != session_data.theme:
+                    if theme not in self.site_themes:
+                        log.warning(_(
+                            "Theme {theme!r} doesn't exist for {vhost}"
+                            .format(theme=theme, vhost=self.vhost_root)))
+                    else:
+                        session_data.theme = theme
+        try:
+
+            try:
+                self._check_access(request)
+
+                if self.redirect is not None:
+                    self.page_redirect(self.redirect, request, skip_parse_url=False)
+
+                if self.parse_url is not None and not skip_parse_url:
+                    if self.url_cache:
+                        profile = self.get_profile(request)
+                        try:
+                            cache_url = self.cached_urls[profile][request.uri]
+                        except KeyError:
+                            # no cache for this URI yet
+                            #  we do normal URL parsing, and then the cache
+                            await as_deferred(self.parse_url, self, request)
+                            self._cache_url(request, profile)
+                        else:
+                            log.debug(f"using URI cache for {self}")
+                            cache_url.use(request)
+                    else:
+                        await as_deferred(self.parse_url, self, request)
+
+                if self.add_breadcrumb is None:
+                    label = (
+                        self.label
+                        or self.name
+                        or self.url[self.url.rfind('/')+1:]
+                    )
+                    breadcrumb = {
+                        "url": self.url,
+                        "label": label.title(),
+                    }
+                    request.template_data["breadcrumbs"].append(breadcrumb)
+                else:
+                    await as_deferred(
+                        self.add_breadcrumb,
+                        self,
+                        request,
+                        request.template_data["breadcrumbs"]
+                    )
+
+                self._subpages_handler(request)
+
+                if request.method not in (C.HTTP_METHOD_GET, C.HTTP_METHOD_POST):
+                    # only HTTP GET and POST are handled so far
+                    self.page_error(request, C.HTTP_BAD_REQUEST)
+
+                if request.method == C.HTTP_METHOD_POST:
+                    if self.on_data_post == 'continue':
+                        pass
+                    elif self.on_data_post is None:
+                        # if we don't have on_data_post, the page was not expecting POST
+                        # so we return an error
+                        self.page_error(request, C.HTTP_BAD_REQUEST)
+                    else:
+                        await self._on_data_post(request)
+                    # by default, POST follow normal behaviour after on_data_post is called
+                    # this can be changed by a redirection or other method call in on_data_post
+
+                if self.dynamic:
+                    self._prepare_dynamic(request)
+
+                if self.prepare_render:
+                    await as_deferred(self.prepare_render, self, request)
+
+                if self.template:
+                    rendered = self._render_template(request)
+                elif self.render_method:
+                    rendered = await as_deferred(self.render_method, self, request)
+                else:
+                    raise exceptions.InternalError(
+                        "No method set to render page, please set a template or use a "
+                        "render method"
+                    )
+
+                self.write_data(rendered, request)
+
+            except failure.Failure as f:
+                # we have to unpack the Failure to catch the right Exception
+                raise f.value
+
+        except exceptions.CancelError:
+            pass
+        except BridgeException as e:
+            if e.condition == 'not-allowed':
+                log.warning("not allowed exception catched")
+                self.page_error(request, C.HTTP_FORBIDDEN)
+            elif e.condition == 'item-not-found' or e.classname == 'NotFound':
+                self.page_error(request, C.HTTP_NOT_FOUND)
+            elif e.condition == 'remote-server-not-found':
+                self.page_error(request, C.HTTP_NOT_FOUND)
+            elif e.condition == 'forbidden':
+                if self.get_profile(request) is None:
+                    log.debug("access forbidden, we're redirecting to log-in page")
+                    self.http_redirect(request, self.get_page_redirect_url(request))
+                else:
+                    self.page_error(request, C.HTTP_FORBIDDEN)
+            else:
+                log.error(
+                    _("Uncatched bridge exception for HTTP request on {url}: {e}\n"
+                      "page name: {name}\npath: {path}\nURL: {full_url}\n{tb}")
+                    .format(
+                        url=self.url,
+                        e=e,
+                        name=self.name or "",
+                        path=self.root_dir,
+                        full_url=request.URLPath(),
+                        tb=traceback.format_exc(),
+                    )
+                )
+                try:
+                    self.page_error(request, C.HTTP_INTERNAL_ERROR)
+                except exceptions.CancelError:
+                    pass
+        except Exception as e:
+            log.error(
+                _("Uncatched error for HTTP request on {url}: {e}\npage name: "
+                  "{name}\npath: {path}\nURL: {full_url}\n{tb}")
+                .format(
+                    url=self.url,
+                    e=e,
+                    name=self.name or "",
+                    path=self.root_dir,
+                    full_url=request.URLPath(),
+                    tb=traceback.format_exc(),
+                )
+            )
+            try:
+                self.page_error(request, C.HTTP_INTERNAL_ERROR)
+            except exceptions.CancelError:
+                pass
+
+    def render_GET(self, request):
+        defer.ensureDeferred(self.render_page(request))
+        return server.NOT_DONE_YET
+
+    def render_POST(self, request):
+        defer.ensureDeferred(self.render_page(request))
+        return server.NOT_DONE_YET
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/pages_tools.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+
+# Libervia Web frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""Helper methods for common operations on pages"""
+
+from twisted.internet import defer
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import data_format
+from libervia.web.server.constants import Const as C
+
+
+log = getLogger(__name__)
+
+
+def deserialise(comments_data_s):
+    return data_format.deserialise(comments_data_s)
+
+
+def retrieve_comments(self, service, node, profile, pass_exceptions=True):
+    """Retrieve comments from server and convert them to data objects
+
+    @param service(unicode): service holding the comments
+    @param node(unicode): node to retrieve
+    @param profile(unicode): profile of the user willing to find comments
+    @param pass_exceptions(bool): if True bridge exceptions will be ignored but logged
+        else exception will be raised
+    """
+    try:
+        d = self.host.bridge_call(
+            "mb_get", service, node, C.NO_LIMIT, [], data_format.serialise({}), profile
+        )
+    except Exception as e:
+        if not pass_exceptions:
+            raise e
+        else:
+            log.warning(
+                _("Can't get comments at {node} (service: {service}): {msg}").format(
+                    service=service, node=node, msg=e
+                )
+            )
+            return defer.succeed([])
+
+    d.addCallback(deserialise)
+    return d
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/proxy.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,79 @@
+#!/usr/bin/env python3
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from twisted.web import proxy
+from twisted.python.compat import urlquote
+from twisted.internet import address
+from libervia.backend.core.log import getLogger
+from libervia.web.server.constants import Const as C
+
+log = getLogger(__name__)
+
+
+
+class SatProxyClient(proxy.ProxyClient):
+
+    def handleHeader(self, key, value):
+        if key.lower() == b"x-frame-options":
+            value = b"sameorigin"
+        elif key.lower() == b"content-security-policy":
+            value = value.replace(b"frame-ancestors 'none'", b"frame-ancestors 'self'")
+
+        super().handleHeader(key, value)
+
+
+class SatProxyClientFactory(proxy.ProxyClientFactory):
+    protocol = SatProxyClient
+
+
+class SatReverseProxyResource(proxy.ReverseProxyResource):
+    """Resource Proxy rewritting headers to allow embedding in iframe on same domain"""
+    proxyClientFactoryClass = SatProxyClientFactory
+
+    def getChild(self, path, request):
+        return SatReverseProxyResource(
+            self.host, self.port,
+            self.path + b'/' + urlquote(path, safe=b"").encode('utf-8'),
+            self.reactor
+        )
+
+    def render(self, request):
+        # Forwarded and X-Forwarded-xxx headers can be set
+        # if we have behind an other proxy
+        if ((not request.getHeader(C.H_FORWARDED)
+             and not request.getHeader(C.H_X_FORWARDED_HOST))):
+            forwarded_data = []
+            addr = request.getClientAddress()
+            if ((isinstance(addr, address.IPv4Address)
+                 or isinstance(addr, address.IPv6Address))):
+                request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_FOR, [addr.host])
+                forwarded_data.append(f"for={addr.host}")
+            host = request.getHeader("host")
+            if host is None:
+                port = request.getHost().port
+                hostname = request.getRequestHostname()
+                host = hostname if port in (80, 443) else f"{hostname}:{port}"
+            request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_HOST, [host])
+            forwarded_data.append(f"host={host}")
+            proto = "https" if request.isSecure() else "http"
+            request.requestHeaders.setRawHeaders(C.H_X_FORWARDED_PROTO, [proto])
+            forwarded_data.append(f"proto={proto}")
+            request.requestHeaders.setRawHeaders(
+                C.H_FORWARDED, [";".join(forwarded_data)]
+            )
+
+        return super().render(request)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/resources.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,708 @@
+#!/usr/bin/env python3
+
+# Libervia Web
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import os.path
+from pathlib import Path
+import urllib.error
+import urllib.parse
+import urllib.request
+
+from twisted.internet import defer
+from twisted.web import server
+from twisted.web import static
+from twisted.web import resource as web_resource
+
+from libervia.web.server.constants import Const as C
+from libervia.web.server.utils import quote
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import D_, _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import uri as common_uri
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common.utils import OrderedSet, recursive_update
+
+from . import proxy
+
+log = getLogger(__name__)
+
+
+class ProtectedFile(static.File):
+    """A static.File class which doesn't show directory listing"""
+
+    def __init__(self, path, *args, **kwargs):
+        if "defaultType" not in kwargs and len(args) < 2:
+            # defaultType is second positional argument, and Twisted uses it
+            # in File.createSimilarFile, so we set kwargs only if it is missing
+            # in kwargs and it is not in a positional argument
+            kwargs["defaultType"] = "application/octet-stream"
+        super(ProtectedFile, self).__init__(str(path), *args, **kwargs)
+
+    def directoryListing(self):
+        return web_resource.NoResource()
+
+
+    def getChild(self, path, request):
+        return super().getChild(path, request)
+
+    def getChildWithDefault(self, path, request):
+        return super().getChildWithDefault(path, request)
+
+    def getChildForRequest(self, request):
+        return super().getChildForRequest(request)
+
+
+class LiberviaRootResource(ProtectedFile):
+    """Specialized resource for Libervia root
+
+    handle redirections declared in libervia.conf
+    """
+
+    def __init__(self, host, host_name, site_name, site_path, *args, **kwargs):
+        ProtectedFile.__init__(self, *args, **kwargs)
+        self.host = host
+        self.host_name = host_name
+        self.site_name = site_name
+        self.site_path = Path(site_path)
+        self.default_theme = self.config_get('theme')
+        if self.default_theme is None:
+            if not host_name:
+                # FIXME: we use bulma theme by default for main site for now
+                #   as the development is focusing on this one, and default theme may
+                #   be broken
+                self.default_theme = 'bulma'
+            else:
+                self.default_theme =  C.TEMPLATE_THEME_DEFAULT
+        self.site_themes = set()
+        self.named_pages = {}
+        self.browser_modules = {}
+        # template dynamic data used in all pages
+        self.dyn_data_common = {"scripts": OrderedSet()}
+        for theme, data in host.renderer.get_themes_data(site_name).items():
+            # we check themes for browser metadata, and merge them here if found
+            self.site_themes.add(theme)
+            browser_meta = data.get('browser_meta')
+            if browser_meta is not None:
+                log.debug(f"merging browser metadata from theme {theme}: {browser_meta}")
+                recursive_update(self.browser_modules, browser_meta)
+            browser_path = data.get('browser_path')
+            if browser_path is not None:
+                self.browser_modules.setdefault('themes_browser_paths', set()).add(
+                    browser_path)
+                try:
+                    next(browser_path.glob("*.py"))
+                except StopIteration:
+                    pass
+                else:
+                    log.debug(f"found brython script(s) for theme {theme}")
+                    self.browser_modules.setdefault('brython', []).append(
+                        {
+                            "path": browser_path,
+                            "url_hash": None,
+                            "url_prefix": f"__t_{theme}"
+                        }
+                    )
+
+        self.uri_callbacks = {}
+        self.pages_redirects = {}
+        self.cached_urls = {}
+        self.main_menu = None
+        # map Libervia application names => data
+        self.libervia_apps = {}
+        self.build_path = host.get_build_path(site_name)
+        self.build_path.mkdir(parents=True, exist_ok=True)
+        self.dev_build_path = host.get_build_path(site_name, dev=True)
+        self.dev_build_path.mkdir(parents=True, exist_ok=True)
+        self.putChild(
+            C.BUILD_DIR.encode(),
+            ProtectedFile(
+                self.build_path,
+                defaultType="application/octet-stream"),
+        )
+
+    def __str__(self):
+        return (
+            f"Root resource for {self.host_name or 'default host'} using "
+            f"{self.site_name or 'default site'} at {self.site_path} and deserving "
+            f"files at {self.path}"
+        )
+
+    def config_get(self, key, default=None, value_type=None):
+        """Retrieve configuration for this site
+
+        params are the same as for [Libervia.config_get]
+        """
+        return self.host.config_get(self, key, default, value_type)
+
+    def get_front_url(self, theme):
+        return Path(
+            '/',
+            C.TPL_RESOURCE,
+            self.site_name or C.SITE_NAME_DEFAULT,
+            C.TEMPLATE_TPL_DIR,
+            theme)
+
+    def add_resource_to_path(self, path: str, resource: web_resource.Resource) -> None:
+        """Add a resource to the given path
+
+        A "NoResource" will be used for all intermediate segments
+        """
+        segments, __, last_segment = path.rpartition("/")
+        url_segments = segments.split("/") if segments else []
+        current = self
+        for segment in url_segments:
+            resource = web_resource.NoResource()
+            current.putChild(segment, resource)
+            current = resource
+
+        current.putChild(
+            last_segment.encode('utf-8'),
+            resource
+        )
+
+    async def _start_app(self, app_name, extra=None) -> dict:
+        """Start a Libervia App
+
+        @param app_name: canonical application name
+        @param extra: extra parameter to configure app
+        @return: app data
+            app data will not include computed exposed data, at this needs to wait for the
+            app to be started
+        """
+        if extra is None:
+            extra = {}
+        log.info(_(
+            "starting application {app_name}").format(app_name=app_name))
+        app_data = data_format.deserialise(
+            await self.host.bridge_call(
+                "application_start", app_name, data_format.serialise(extra)
+            )
+        )
+        if app_data.get("started", False):
+            log.debug(f"application {app_name!r} is already started or starting")
+            # we do not await on purpose, the workflow should not be blocking at this
+            # point
+            defer.ensureDeferred(self._on_app_started(app_name, app_data["instance"]))
+        else:
+            self.host.apps_cb[app_data["instance"]] = self._on_app_started
+        return app_data
+
+    async def _on_app_started(
+        self,
+        app_name: str,
+        instance_id: str
+    ) -> None:
+        exposed_data = self.libervia_apps[app_name] = data_format.deserialise(
+            await self.host.bridge_call("application_exposed_get", app_name, "", "")
+        )
+
+        try:
+            web_port = int(exposed_data['ports']['web'].split(':')[1])
+        except (KeyError, ValueError):
+            log.warning(_(
+                "no web port found for application {app_name!r}, can't use it "
+                ).format(app_name=app_name))
+            raise exceptions.DataError("no web port found")
+
+        try:
+            url_prefix = exposed_data['url_prefix'].strip().rstrip('/')
+        except (KeyError, AttributeError) as e:
+            log.warning(_(
+                "no URL prefix specified for this application, we can't embed it: {msg}")
+                .format(msg=e))
+            raise exceptions.DataError("no URL prefix")
+
+        if not url_prefix.startswith('/'):
+            raise exceptions.DataError(
+                f"invalid URL prefix, it must start with '/': {url_prefix!r}")
+
+        res = proxy.SatReverseProxyResource(
+            "localhost",
+            web_port,
+            url_prefix.encode()
+        )
+        self.add_resource_to_path(url_prefix, res)
+        log.info(
+            f"Resource for app {app_name!r} (instance {instance_id!r}) has been added"
+        )
+
+    async def _init_redirections(self, options):
+        url_redirections = options["url_redirections_dict"]
+
+        url_redirections = url_redirections.get(self.site_name, {})
+
+        ## redirections
+        self.redirections = {}
+        self.inv_redirections = {}  # new URL to old URL map
+
+        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 get_url 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
+                        )
+                    )
+                    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._normalize_url(old)
+
+                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.get_page_by_name(new["page"])
+                            url = page.get_url(*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)
+
+                # we handle the known URL schemes
+                if new_url.scheme == "xmpp":
+                    location = self.get_page_path_from_uri(new)
+                    if location is None:
+                        log.warning(
+                            _("ignoring redirection, no page found to handle this URI: "
+                              "{uri}").format(uri=new))
+                        continue
+                    request_data = self._get_request_data(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._get_request_data(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.add_resource_to_path(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 == "libervia-app":
+                    # a Libervia application
+
+                    app_name = urllib.parse.unquote(new_url.path).lower().strip()
+                    extra = {"url_prefix": f"/{old}"}
+                    try:
+                        await self._start_app(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(
+                        f"[{self.host_name}] Added redirection from /{old} to "
+                        f"application {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
+                    if host is None or port is None:
+                        raise ValueError(
+                            "invalid host or port in proxy redirection, please check your "
+                            "configuration: {new_url.geturl()}"
+                        )
+                    url_prefix = (new_url.path or old).rstrip('/')
+                    res = proxy.SatReverseProxyResource(
+                        host,
+                        port,
+                        url_prefix.encode(),
+                    )
+                    self.add_resource_to_path(old, res)
+                    log.info(
+                        f"[{self.host_name}] Added redirection from /{old} to reverse proxy "
+                        f"{new_url.netloc} with URL prefix {url_prefix}/"
+                    )
+
+                    # 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
+                        )
+                    )
+
+                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:
+            self.redirections[""] = self._get_request_data(C.LIBERVIA_PAGE_START)
+
+    async def _set_menu(self, menus):
+        menus = menus.get(self.site_name, [])
+        main_menu = []
+        for menu in menus:
+            if not menu:
+                msg = _("menu item can't be empty")
+                log.error(msg)
+                raise ValueError(msg)
+            elif isinstance(menu, list):
+                if len(menu) != 2:
+                    msg = _(
+                        "menu item as list must be in the form [page_name, absolue URL]"
+                    )
+                    log.error(msg)
+                    raise ValueError(msg)
+                page_name, url = menu
+            elif menu.startswith("libervia-app:"):
+                app_name = menu[13:].strip().lower()
+                app_data = await self._start_app(app_name)
+                exposed_data = app_data["expose"]
+                front_url = exposed_data['front_url']
+                options = self.host.options
+                url_redirections = options["url_redirections_dict"].setdefault(
+                    self.site_name, {}
+                )
+                if front_url in url_redirections:
+                    raise exceptions.ConflictError(
+                        f"There is already a redirection from {front_url!r}, can't add "
+                        f"{app_name!r}")
+
+                url_redirections[front_url] = {
+                    "page": 'embed_app',
+                    "path_args": [app_name]
+                }
+
+                page_name = exposed_data.get('web_label', app_name).title()
+                url = front_url
+
+                log.debug(
+                    f"Application {app_name} added to menu of {self.site_name}"
+                )
+            else:
+                page_name = menu
+                try:
+                    url = self.get_page_by_name(page_name).url
+                except KeyError as e:
+                    log_msg = _("Can'find a named page ({msg}), please check "
+                                "menu_json in configuration.").format(msg=e.args[0])
+                    log.error(log_msg)
+                    raise exceptions.ConfigError(log_msg)
+            main_menu.append((page_name, url))
+        self.main_menu = main_menu
+
+    def _normalize_url(self, url, lower=True):
+        """Return URL normalized for self.redirections dict
+
+        @param url(unicode): URL to normalize
+        @param lower(bool): lower case of url if True
+        @return (str): normalized URL
+        """
+        if lower:
+            url = url.lower()
+        return "/".join((p for p in url.split("/") if p))
+
+    def _get_request_data(self, uri):
+        """Return data needed to redirect request
+
+        @param url(unicode): destination url
+        @return (tuple(list[str], str, str, dict): tuple with
+            splitted path as in Request.postpath
+            uri as in Request.uri
+            path as in Request.path
+            args as in Request.args
+        """
+        uri = uri
+        # XXX: we reuse code from twisted.web.http.py here
+        #      as we need to have the same behaviour
+        x = uri.split("?", 1)
+
+        if len(x) == 1:
+            path = uri
+            args = {}
+        else:
+            path, argstring = x
+            args = urllib.parse.parse_qs(argstring, True)
+
+        # XXX: splitted path case must not be changed, as it may be significant
+        #      (e.g. for blog items)
+        return (
+            self._normalize_url(path, lower=False).split("/"),
+            uri,
+            path,
+            args,
+        )
+
+    def _redirect(self, request, request_data):
+        """Redirect an URL by rewritting request
+
+        this is *NOT* a HTTP redirection, but equivalent to URL rewritting
+        @param request(web.http.request): original request
+        @param request_data(tuple): data returned by self._get_request_data
+        @return (web_resource.Resource): resource to use
+        """
+        # recursion check
+        try:
+            request._redirected
+        except AttributeError:
+            pass
+        else:
+            try:
+                __, uri, __, __ = request_data
+            except ValueError:
+                uri = ""
+            log.error(D_( "recursive redirection, please fix this URL:\n"
+                          "{old} ==> {new}").format(
+                          old=request.uri.decode("utf-8"), new=uri))
+            return web_resource.NoResource()
+
+        request._redirected = True  # here to avoid recursive redirections
+
+        if isinstance(request_data, dict):
+            if request_data["type"] == "page":
+                try:
+                    page = self.get_page_by_name(request_data["page"])
+                except KeyError:
+                    log.error(
+                        _(
+                            'Can\'t find page named "{name}" requested in redirection'
+                        ).format(name=request_data["page"])
+                    )
+                    return web_resource.NoResource()
+                path_args = [pa.encode('utf-8') for pa in request_data["path_args"]]
+                request.postpath = path_args + request.postpath
+
+                try:
+                    request.args.update(request_data["query_args"])
+                except (TypeError, ValueError):
+                    log.error(
+                        _("Invalid args in redirection: {query_args}").format(
+                            query_args=request_data["query_args"]
+                        )
+                    )
+                    return web_resource.NoResource()
+                return page
+            else:
+                raise exceptions.InternalError("unknown request_data type")
+        else:
+            path_list, uri, path, args = request_data
+            path_list = [p.encode('utf-8') for p in path_list]
+            log.debug(
+                "Redirecting URL {old} to {new}".format(
+                    old=request.uri.decode('utf-8'), new=uri
+                )
+            )
+            # we change the request to reflect the new url
+            request.postpath = path_list[1:] + request.postpath
+            request.args.update(args)
+
+        # we start again to look for a child with the new url
+        return self.getChildWithDefault(path_list[0], request)
+
+    def get_page_by_name(self, name):
+        """Retrieve page instance from its name
+
+        @param name(unicode): name of the page
+        @return (LiberviaPage): page instance
+        @raise KeyError: the page doesn't exist
+        """
+        return self.named_pages[name]
+
+    def get_page_path_from_uri(self, uri):
+        """Retrieve page URL from xmpp: URI
+
+        @param uri(unicode): URI with a xmpp: scheme
+        @return (unicode,None): absolute path (starting from root "/") to page handling
+            the URI.
+            None is returned if no page has been registered for this URI
+        """
+        uri_data = common_uri.parse_xmpp_uri(uri)
+        try:
+            page, cb = self.uri_callbacks[uri_data["type"], uri_data["sub_type"]]
+        except KeyError:
+            url = None
+        else:
+            url = cb(page, uri_data)
+        if url is None:
+            # no handler found
+            # we try to find a more generic one
+            try:
+                page, cb = self.uri_callbacks[uri_data["type"], None]
+            except KeyError:
+                pass
+            else:
+                url = cb(page, uri_data)
+        return url
+
+    def getChildWithDefault(self, name, request):
+        # XXX: this method is overriden only for root url
+        #      which is the only ones who need to be handled before other children
+        if name == b"" and not request.postpath:
+            return self._redirect(request, self.redirections[""])
+        return super(LiberviaRootResource, self).getChildWithDefault(name, request)
+
+    def getChild(self, name, request):
+        resource = super(LiberviaRootResource, self).getChild(name, request)
+
+        if isinstance(resource, web_resource.NoResource):
+            # 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), -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]
+                    request.postpath = path_elt[idx:]
+                    return self._redirect(request, request_data)
+
+        return resource
+
+    def putChild(self, path, resource):
+        """Add a child to the root resource"""
+        if not isinstance(path, bytes):
+            raise ValueError("path must be specified in bytes")
+        if not isinstance(resource, web_resource.EncodingResourceWrapper):
+            # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders)
+            resource = web_resource.EncodingResourceWrapper(
+                resource, [server.GzipEncoderFactory()])
+
+        super(LiberviaRootResource, self).putChild(path, resource)
+
+    def createSimilarFile(self, path):
+        # XXX: this method need to be overriden to avoid recreating a LiberviaRootResource
+
+        f = LiberviaRootResource.__base__(
+            path, self.defaultType, self.ignoredExts, self.registry
+        )
+        # refactoring by steps, here - constructor should almost certainly take these
+        f.processors = self.processors
+        f.indexNames = self.indexNames[:]
+        f.childNotFound = self.childNotFound
+        return f
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/restricted_bridge.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,191 @@
+#!/usr/bin/env python3
+
+# Libervia: a SàT frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from libervia.backend.tools.common import data_format
+from libervia.backend.core import exceptions
+from libervia.web.server.constants import Const as C
+
+
+class RestrictedBridge:
+    """bridge with limited access, which can be used in browser
+
+    Only a few method are implemented, with potentially dangerous argument controlled.
+    Security limit is used
+    """
+
+    def __init__(self, host):
+        self.host = host
+        self.security_limit = C.SECURITY_LIMIT
+
+    def no_service_profile(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 action_launch(
+        self, callback_id: str, data_s: str, profile: str
+    ) -> str:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "action_launch", callback_id, data_s, profile
+        )
+
+    async def call_start(self, entity: str, call_data_s: str, profile: str) -> None:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "call_start", entity, call_data_s, profile
+        )
+
+    async def call_end(self, session_id: str, call_data: str, profile: str) -> None:
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "call_end", session_id, call_data, profile
+        )
+
+    async def contacts_get(self, profile):
+        return await self.host.bridge_call("contacts_get", profile)
+
+    async def external_disco_get(self, entity, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "external_disco_get", entity, profile)
+
+    async def ice_candidates_add(self, session_id, media_ice_data_s, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ice_candidates_add", session_id, media_ice_data_s, profile
+        )
+
+    async def identity_get(self, entity, metadata_filter, use_cache, profile):
+        return await self.host.bridge_call(
+            "identity_get", entity, metadata_filter, use_cache, profile)
+
+    async def identities_get(self, entities, metadata_filter, profile):
+        return await self.host.bridge_call(
+            "identities_get", entities, metadata_filter, profile)
+
+    async def identities_base_get(self, profile):
+        return await self.host.bridge_call(
+            "identities_base_get", profile)
+
+    async def ps_node_delete(self, service_s, node, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_node_delete", service_s, node, profile)
+
+    async def ps_node_affiliations_set(self, service_s, node, affiliations, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_node_affiliations_set", service_s, node, affiliations, profile)
+
+    async def ps_item_retract(self, service_s, node, item_id, notify, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_item_retract", service_s, node, item_id, notify, profile)
+
+    async def mb_preview(self, service_s, node, data, profile):
+        return await self.host.bridge_call(
+            "mb_preview", service_s, node, data, profile)
+
+    async def list_set(self, service_s, node, values, schema, item_id, extra, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "list_set", service_s, node, values, "", item_id, "", profile)
+
+
+    async def file_http_upload_get_slot(
+        self, filename, size, content_type, upload_jid, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "file_http_upload_get_slot", filename, size, content_type,
+            upload_jid, profile)
+
+    async def file_sharing_delete(
+        self, service_jid, path, namespace, profile):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "file_sharing_delete", service_jid, path, namespace, profile)
+
+    async def interests_file_sharing_register(
+        self, service, repos_type, namespace, path, name, extra_s, profile
+    ):
+        self.no_service_profile(profile)
+        if extra_s:
+            # we only allow "thumb_url" here
+            extra = data_format.deserialise(extra_s)
+            if "thumb_url" in extra:
+                extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]})
+            else:
+                extra_s = ""
+
+        return await self.host.bridge_call(
+            "interests_file_sharing_register", service, repos_type, namespace, path, name,
+            extra_s, profile
+        )
+
+    async def interest_retract(
+        self, service_jid, item_id, profile
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "interest_retract", service_jid, item_id, profile)
+
+    async def ps_invite(
+        self, invitee_jid_s, service_s, node, item_id, name, extra_s, profile
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "ps_invite", invitee_jid_s, service_s, node, item_id, name, extra_s, profile
+        )
+
+    async def fis_invite(
+        self, invitee_jid_s, service_s, repos_type, namespace, path, name, extra_s,
+        profile
+    ):
+        self.no_service_profile(profile)
+        if extra_s:
+            # we only allow "thumb_url" here
+            extra = data_format.deserialise(extra_s)
+            if "thumb_url" in extra:
+                extra_s = data_format.serialise({"thumb_url": extra["thumb_url"]})
+            else:
+                extra_s = ""
+
+        return await self.host.bridge_call(
+            "fis_invite", invitee_jid_s, service_s, repos_type, namespace, path, name,
+            extra_s, profile
+        )
+
+    async def fis_affiliations_set(
+        self, service_s, namespace, path, affiliations, profile
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "fis_affiliations_set", service_s, namespace, path, affiliations, profile
+        )
+
+    async def invitation_simple_create(
+        self, invitee_email, invitee_name, url_template, extra_s, profile
+    ):
+        self.no_service_profile(profile)
+        return await self.host.bridge_call(
+            "invitation_simple_create", invitee_email, invitee_name, url_template, extra_s,
+            profile
+        )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/server.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,1374 @@
+#!/usr/bin/env python3
+
+# Libervia Web
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from functools import partial
+import os.path
+from pathlib import Path
+import re
+import sys
+import time
+from typing import Callable, Dict, Optional
+import urllib.error
+import urllib.parse
+import urllib.request
+
+from twisted.application import service
+from twisted.internet import defer, inotify, reactor
+from twisted.python import failure
+from twisted.python import filepath
+from twisted.python.components import registerAdapter
+from twisted.web import server
+from twisted.web import static
+from twisted.web import resource as web_resource
+from twisted.web import util as web_util
+from twisted.web import vhost
+from twisted.words.protocols.jabber import jid
+
+import libervia.web
+from libervia.web.server import websockets
+from libervia.web.server import session_iface
+from libervia.web.server.constants import Const as C
+from libervia.web.server.pages import LiberviaPage
+from libervia.web.server.tasks.manager import TasksManager
+from libervia.web.server.utils import ProgressHandler
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import utils
+from libervia.backend.tools import config
+from libervia.backend.tools.common import regex
+from libervia.backend.tools.common import template
+from libervia.backend.tools.common import data_format
+from libervia.backend.tools.common import tls
+from libervia.frontends.bridge.bridge_frontend import BridgeException
+from libervia.frontends.bridge.dbus_bridge import BridgeExceptionNoService, bridge
+from libervia.frontends.bridge.dbus_bridge import const_TIMEOUT as BRIDGE_TIMEOUT
+
+from .resources import LiberviaRootResource, ProtectedFile
+from .restricted_bridge import RestrictedBridge
+
+log = getLogger(__name__)
+
+
+DEFAULT_MASK = (inotify.IN_CREATE | inotify.IN_MODIFY | inotify.IN_MOVE_SELF
+                | inotify.IN_MOVED_TO)
+
+
+class SysExit(Exception):
+
+    def __init__(self, exit_code, message=""):
+        self.exit_code = exit_code
+        self.message = message
+
+    def __str__(self):
+        return f"System Exit({self.exit_code}): {self.message}"
+
+
+class FilesWatcher(object):
+    """Class to check files modifications using iNotify"""
+    _notifier = None
+
+    def __init__(self, host):
+        self.host = host
+
+    @property
+    def notifier(self):
+        if self._notifier == None:
+            notifier = self.__class__._notifier = inotify.INotify()
+            notifier.startReading()
+        return self._notifier
+
+    def _check_callback(self, dir_path, callback, recursive):
+        # Twisted doesn't add callback if a watcher was already set on a path
+        # but in dev mode Libervia watches whole sites + internal path can be watched
+        # by tasks, so several callbacks must be called on some paths.
+        # This method check that the new callback is indeed present in the desired path
+        # and add it otherwise.
+        # FIXME: this should probably be fixed upstream
+        if recursive:
+            for child in dir_path.walk():
+                if child.isdir():
+                    self._check_callback(child, callback, recursive=False)
+        else:
+            watch_id = self.notifier._isWatched(dir_path)
+            if watch_id is None:
+                log.warning(
+                    f"There is no watch ID for path {dir_path}, this should not happen"
+                )
+            else:
+                watch_point = self.notifier._watchpoints[watch_id]
+                if callback not in watch_point.callbacks:
+                    watch_point.callbacks.append(callback)
+
+    def watch_dir(self, dir_path, callback, mask=DEFAULT_MASK, auto_add=False,
+                 recursive=False, **kwargs):
+        dir_path = str(dir_path)
+        log.info(_("Watching directory {dir_path}").format(dir_path=dir_path))
+        wrapped_callback = lambda __, filepath, mask: callback(
+            self.host, filepath, inotify.humanReadableMask(mask), **kwargs)
+        callbacks = [wrapped_callback]
+        dir_path = filepath.FilePath(dir_path)
+        self.notifier.watch(
+            dir_path, mask=mask, autoAdd=auto_add, recursive=recursive,
+            callbacks=callbacks)
+        self._check_callback(dir_path, wrapped_callback, recursive)
+
+
+class WebSession(server.Session):
+    sessionTimeout = C.SESSION_TIMEOUT
+
+    def __init__(self, *args, **kwargs):
+        self.__lock = False
+        server.Session.__init__(self, *args, **kwargs)
+
+    def lock(self):
+        """Prevent session from expiring"""
+        self.__lock = True
+        self._expireCall.reset(sys.maxsize)
+
+    def unlock(self):
+        """Allow session to expire again, and touch it"""
+        self.__lock = False
+        self.touch()
+
+    def touch(self):
+        if not self.__lock:
+            server.Session.touch(self)
+
+
+class WaitingRequests(dict):
+    def set_request(self, request, profile, register_with_ext_jid=False):
+        """Add the given profile to the waiting list.
+
+        @param request (server.Request): the connection request
+        @param profile (str): %(doc_profile)s
+        @param register_with_ext_jid (bool): True if we will try to register the
+            profile with an external XMPP account credentials
+        """
+        dc = reactor.callLater(BRIDGE_TIMEOUT, self.purge_request, profile)
+        self[profile] = (request, dc, register_with_ext_jid)
+
+    def purge_request(self, profile):
+        """Remove the given profile from the waiting list.
+
+        @param profile (str): %(doc_profile)s
+        """
+        try:
+            dc = self[profile][1]
+        except KeyError:
+            return
+        if dc.active():
+            dc.cancel()
+        del self[profile]
+
+    def get_request(self, profile):
+        """Get the waiting request for the given profile.
+
+        @param profile (str): %(doc_profile)s
+        @return: the waiting request or None
+        """
+        return self[profile][0] if profile in self else None
+
+    def get_register_with_ext_jid(self, profile):
+        """Get the value of the register_with_ext_jid parameter.
+
+        @param profile (str): %(doc_profile)s
+        @return: bool or None
+        """
+        return self[profile][2] if profile in self else None
+
+
+class LiberviaWeb(service.Service):
+    debug = defer.Deferred.debug  # True if twistd/Libervia is launched in debug mode
+
+    def __init__(self, options):
+        self.options = options
+        websockets.host = self
+
+    def _init(self):
+        # we do init here and not in __init__ to avoid doule initialisation with twistd
+        # this _init is called in startService
+        self.initialised = defer.Deferred()
+        self.waiting_profiles = WaitingRequests()  # FIXME: should be removed
+        self._main_conf = None
+        self.files_watcher = FilesWatcher(self)
+
+        if self.options["base_url_ext"]:
+            self.base_url_ext = self.options.pop("base_url_ext")
+            if self.base_url_ext[-1] != "/":
+                self.base_url_ext += "/"
+            self.base_url_ext_data = urllib.parse.urlsplit(self.base_url_ext)
+        else:
+            self.base_url_ext = None
+            # we split empty string anyway so we can do things like
+            # scheme = self.base_url_ext_data.scheme or 'https'
+            self.base_url_ext_data = urllib.parse.urlsplit("")
+
+        if not self.options["port_https_ext"]:
+            self.options["port_https_ext"] = self.options["port_https"]
+
+        self._cleanup = []
+
+        self.sessions = {}  # key = session value = user
+        self.prof_connected = set()  # Profiles connected
+        self.ns_map = {}  # map of short name to namespaces
+
+        ## bridge ##
+        self._bridge_retry = self.options['bridge-retries']
+        self.bridge = bridge()
+        self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
+
+        ## libervia app callbacks ##
+        # mapping instance id to the callback to call on "started" signal
+        self.apps_cb: Dict[str, Callable] = {}
+
+    @property
+    def roots(self):
+        """Return available virtual host roots
+
+        Root resources are only returned once, even if they are present for multiple
+        named vhosts. Order is not relevant, except for default vhost which is always
+        returned first.
+        @return (list[web_resource.Resource]): all vhost root resources
+        """
+        roots = list(set(self.vhost_root.hosts.values()))
+        default = self.vhost_root.default
+        if default is not None and default not in roots:
+            roots.insert(0, default)
+        return roots
+
+    @property
+    def main_conf(self):
+        """SafeConfigParser instance opened on configuration file (libervia.conf)"""
+        if self._main_conf is None:
+            self._main_conf = config.parse_main_conf(log_filenames=True)
+        return self._main_conf
+
+    def config_get(self, site_root_res, key, default=None, value_type=None):
+        """Retrieve configuration associated to a site
+
+        Section is automatically set to site name
+        @param site_root_res(LiberviaRootResource): resource of the site in use
+        @param key(unicode): key to use
+        @param default: value to use if not found (see [config.config_get])
+        @param value_type(unicode, None): filter to use on value
+            Note that filters are already automatically used when the key finish
+            by a well known suffix ("_path", "_list", "_dict", or "_json")
+            None to use no filter, else can be:
+                - "path": a path is expected, will be normalized and expanded
+
+        """
+        section = site_root_res.site_name.lower().strip() or C.CONFIG_SECTION
+        value = config.config_get(self.main_conf, section, key, default=default)
+        if value_type is not None:
+            if value_type == 'path':
+                v_filter = lambda v: os.path.abspath(os.path.expanduser(v))
+            else:
+                raise ValueError("unknown value type {value_type}".format(
+                    value_type = value_type))
+            if isinstance(value, list):
+                value = [v_filter(v) for v in value]
+            elif isinstance(value, dict):
+                value = {k:v_filter(v) for k,v in list(value.items())}
+            elif value is not None:
+                value = v_filter(value)
+        return value
+
+    def _namespaces_get_cb(self, ns_map):
+        self.ns_map = {str(k): str(v) for k,v in ns_map.items()}
+
+    def _namespaces_get_eb(self, failure_):
+        log.error(_("Can't get namespaces map: {msg}").format(msg=failure_))
+
+    @template.contextfilter
+    def _front_url_filter(self, ctx, relative_url):
+        template_data = ctx['template_data']
+        return os.path.join(
+            '/', C.TPL_RESOURCE, template_data.site or C.SITE_NAME_DEFAULT,
+            C.TEMPLATE_TPL_DIR, template_data.theme, relative_url)
+
+    def _move_first_level_to_dict(self, options, key, keys_to_keep):
+        """Read a config option and put value at first level into u'' dict
+
+        This is useful to put values for Libervia official site directly in dictionary,
+        and to use site_name as keys when external sites are used.
+        options will be modified in place
+        @param options(dict): options to modify
+        @param key(unicode): setting key to modify
+        @param keys_to_keep(list(unicode)): keys allowed in first level
+        """
+        try:
+            conf = options[key]
+        except KeyError:
+            return
+        if not isinstance(conf, dict):
+            options[key] = {'': conf}
+            return
+        default_dict = conf.get('', {})
+        to_delete = []
+        for key, value in conf.items():
+            if key not in keys_to_keep:
+                default_dict[key] = value
+                to_delete.append(key)
+        for key in to_delete:
+            del conf[key]
+        if default_dict:
+            conf[''] = default_dict
+
+    async def check_and_connect_service_profile(self):
+        passphrase = self.options["passphrase"]
+        if not passphrase:
+            raise SysExit(
+                C.EXIT_BAD_ARG,
+                _("No passphrase set for service profile, please check installation "
+                  "documentation.")
+            )
+        try:
+            s_prof_connected = await self.bridge_call("is_connected", C.SERVICE_PROFILE)
+        except BridgeException as e:
+            if e.classname == "ProfileUnknownError":
+                log.info("Service profile doesn't exist, creating it.")
+                try:
+                    xmpp_domain = await self.bridge_call("config_get", "", "xmpp_domain")
+                    xmpp_domain = xmpp_domain.strip()
+                    if not xmpp_domain:
+                        raise SysExit(
+                            C.EXIT_BAD_ARG,
+                            _('"xmpp_domain" must be set to create new accounts, please '
+                              'check documentation')
+                        )
+                    service_profile_jid_s = f"{C.SERVICE_PROFILE}@{xmpp_domain}"
+                    await self.bridge_call(
+                        "in_band_account_new",
+                        service_profile_jid_s,
+                        passphrase,
+                        "",
+                        xmpp_domain,
+                        0,
+                    )
+                except BridgeException as e:
+                    if e.condition == "conflict":
+                        log.info(
+                            _("Service's profile JID {profile_jid} already exists")
+                            .format(profile_jid=service_profile_jid_s)
+                        )
+                    elif e.classname == "UnknownMethod":
+                        raise SysExit(
+                            C.EXIT_BRIDGE_ERROR,
+                            _("Can't create service profile XMPP account, In-Band "
+                              "Registration plugin is not activated, you'll have to "
+                              "create the {profile!r} profile with {profile_jid!r} JID "
+                              "manually.").format(
+                                  profile=C.SERVICE_PROFILE,
+                                  profile_jid=service_profile_jid_s)
+                        )
+                    elif e.condition == "service-unavailable":
+                        raise SysExit(
+                            C.EXIT_BRIDGE_ERROR,
+                            _("Can't create service profile XMPP account, In-Band "
+                              "Registration is not activated on your server, you'll have "
+                              "to create the {profile!r} profile with {profile_jid!r} JID "
+                              "manually.\nNote that you'll need to activate In-Band "
+                              "Registation on your server if you want users to be able "
+                              "to create new account from {app_name}, please check "
+                              "documentation.").format(
+                                  profile=C.SERVICE_PROFILE,
+                                  profile_jid=service_profile_jid_s,
+                                  app_name=C.APP_NAME)
+                        )
+                    elif e.condition == "not-acceptable":
+                        raise SysExit(
+                            C.EXIT_BRIDGE_ERROR,
+                            _("Can't create service profile XMPP account, your XMPP "
+                              "server doesn't allow us to create new accounts with "
+                              "In-Band Registration please check XMPP server "
+                              "configuration: {reason}"
+                              ).format(
+                                  profile=C.SERVICE_PROFILE,
+                                  profile_jid=service_profile_jid_s,
+                                  reason=e.message)
+                        )
+
+                    else:
+                        raise SysExit(
+                            C.EXIT_BRIDGE_ERROR,
+                            _("Can't create service profile XMPP account, you'll have "
+                              "do to it manually: {reason}").format(reason=e.message)
+                        )
+                try:
+                    await self.bridge_call("profile_create", C.SERVICE_PROFILE, passphrase)
+                    await self.bridge_call(
+                        "profile_start_session", passphrase, C.SERVICE_PROFILE)
+                    await self.bridge_call(
+                        "param_set", "JabberID", service_profile_jid_s, "Connection", -1,
+                        C.SERVICE_PROFILE)
+                    await self.bridge_call(
+                        "param_set", "Password", passphrase, "Connection", -1,
+                        C.SERVICE_PROFILE)
+                except BridgeException as e:
+                    raise SysExit(
+                        C.EXIT_BRIDGE_ERROR,
+                        _("Can't create service profile XMPP account, you'll have "
+                          "do to it manually: {reason}").format(reason=e.message)
+                    )
+                log.info(_("Service profile has been successfully created"))
+                s_prof_connected = False
+            else:
+                raise SysExit(C.EXIT_BRIDGE_ERROR, e.message)
+
+        if not s_prof_connected:
+            try:
+                await self.bridge_call(
+                    "connect",
+                    C.SERVICE_PROFILE,
+                    passphrase,
+                    {},
+                )
+            except BridgeException as e:
+                raise SysExit(
+                    C.EXIT_BRIDGE_ERROR,
+                    _("Connection of service profile failed: {reason}").format(reason=e)
+                )
+
+    async def backend_ready(self):
+        log.info(f"Libervia Web v{self.full_version}")
+
+        # settings
+        if self.options['dev-mode']:
+            log.info(_("Developer mode activated"))
+        self.media_dir = await self.bridge_call("config_get", "", "media_dir")
+        self.local_dir = await self.bridge_call("config_get", "", "local_dir")
+        self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR)
+        self.renderer = template.Renderer(self, self._front_url_filter)
+        sites_names = list(self.renderer.sites_paths.keys())
+
+        self._move_first_level_to_dict(self.options, "url_redirections_dict", sites_names)
+        self._move_first_level_to_dict(self.options, "menu_json", sites_names)
+        self._move_first_level_to_dict(self.options, "menu_extra_json", sites_names)
+        menu = self.options["menu_json"]
+        if not '' in menu:
+            menu[''] = C.DEFAULT_MENU
+        for site, value in self.options["menu_extra_json"].items():
+            menu[site].extend(value)
+
+        # service profile
+        if not self.options['build-only']:
+            await self.check_and_connect_service_profile()
+
+        # restricted bridge, the one used by browser code
+        self.restricted_bridge = RestrictedBridge(self)
+
+        # we create virtual hosts and import Libervia pages into them
+        self.vhost_root = vhost.NameVirtualHost()
+        default_site_path = Path(libervia.web.__file__).parent.resolve()
+        # self.sat_root is official Libervia site
+        root_path = default_site_path / C.TEMPLATE_STATIC_DIR
+        self.sat_root = default_root = LiberviaRootResource(
+            host=self, host_name='', site_name='',
+            site_path=default_site_path, path=root_path)
+        if self.options['dev-mode']:
+            self.files_watcher.watch_dir(
+                default_site_path, auto_add=True, recursive=True,
+                callback=LiberviaPage.on_file_change, site_root=self.sat_root,
+                site_path=default_site_path)
+        LiberviaPage.import_pages(self, self.sat_root)
+        tasks_manager = TasksManager(self, self.sat_root)
+        await tasks_manager.parse_tasks()
+        await tasks_manager.run_tasks()
+        # FIXME: handle _set_menu in a more generic way, taking care of external sites
+        await self.sat_root._set_menu(self.options["menu_json"])
+        self.vhost_root.default = default_root
+        existing_vhosts = {b'': default_root}
+
+        for host_name, site_name in self.options["vhosts_dict"].items():
+            if site_name == C.SITE_NAME_DEFAULT:
+                raise ValueError(
+                    f"{C.DEFAULT_SITE_NAME} is reserved and can't be used in vhosts_dict")
+            encoded_site_name = site_name.encode('utf-8')
+            try:
+                site_path = self.renderer.sites_paths[site_name]
+            except KeyError:
+                log.warning(_(
+                    "host {host_name} link to non existing site {site_name}, ignoring "
+                    "it").format(host_name=host_name, site_name=site_name))
+                continue
+            if encoded_site_name in existing_vhosts:
+                # we have an alias host, we re-use existing resource
+                res = existing_vhosts[encoded_site_name]
+            else:
+                # for root path we first check if there is a global static dir
+                # if not, we use default template's static dir
+                root_path = os.path.join(site_path, C.TEMPLATE_STATIC_DIR)
+                if not os.path.isdir(root_path):
+                    root_path = os.path.join(
+                        site_path, C.TEMPLATE_TPL_DIR, C.TEMPLATE_THEME_DEFAULT,
+                        C.TEMPLATE_STATIC_DIR)
+                res = LiberviaRootResource(
+                    host=self,
+                    host_name=host_name,
+                    site_name=site_name,
+                    site_path=site_path,
+                    path=root_path)
+
+                existing_vhosts[encoded_site_name] = res
+
+                if self.options['dev-mode']:
+                    self.files_watcher.watch_dir(
+                        site_path, auto_add=True, recursive=True,
+                        callback=LiberviaPage.on_file_change, site_root=res,
+                        # FIXME: site_path should always be a Path, check code above and
+                        # in template module
+                        site_path=Path(site_path))
+
+                LiberviaPage.import_pages(self, res)
+                # FIXME: default pages are accessible if not overriden by external website
+                #        while necessary for login or re-using existing pages
+                #        we may want to disable access to the page by direct URL
+                #        (e.g. /blog disabled except if called by external site)
+                LiberviaPage.import_pages(self, res, root_path=default_site_path)
+                tasks_manager = TasksManager(self, res)
+                await tasks_manager.parse_tasks()
+                await tasks_manager.run_tasks()
+                await res._set_menu(self.options["menu_json"])
+
+            self.vhost_root.addHost(host_name.encode('utf-8'), res)
+
+        templates_res = web_resource.Resource()
+        self.put_child_all(C.TPL_RESOURCE.encode('utf-8'), templates_res)
+        for site_name, site_path in self.renderer.sites_paths.items():
+            templates_res.putChild(site_name.encode() or C.SITE_NAME_DEFAULT.encode(),
+                                   static.File(site_path))
+
+        d = self.bridge_call("namespaces_get")
+        d.addCallback(self._namespaces_get_cb)
+        d.addErrback(self._namespaces_get_eb)
+
+        # websocket
+        if self.options["connection_type"] in ("https", "both"):
+            wss = websockets.LiberviaPageWSProtocol.get_resource(secure=True)
+            self.put_child_all(b'wss', wss)
+        if self.options["connection_type"] in ("http", "both"):
+            ws = websockets.LiberviaPageWSProtocol.get_resource(secure=False)
+            self.put_child_all(b'ws', ws)
+
+        # following signal is needed for cache handling in Libervia pages
+        self.bridge.register_signal(
+            "ps_event_raw", partial(LiberviaPage.on_node_event, self), "plugin"
+        )
+        self.bridge.register_signal(
+            "message_new", partial(self.on_signal, "message_new")
+        )
+        self.bridge.register_signal(
+            "call_accepted", partial(self.on_signal, "call_accepted"), "plugin"
+        )
+        self.bridge.register_signal(
+            "call_ended", partial(self.on_signal, "call_ended"), "plugin"
+        )
+        self.bridge.register_signal(
+            "ice_candidates_new", partial(self.on_signal, "ice_candidates_new"), "plugin"
+        )
+        self.bridge.register_signal(
+            "action_new", self.action_new_handler,
+        )
+
+        # libervia applications handling
+        self.bridge.register_signal(
+            "application_started", self.application_started_handler, "plugin"
+        )
+        self.bridge.register_signal(
+            "application_error", self.application_error_handler, "plugin"
+        )
+
+        #  Progress handling
+        self.bridge.register_signal(
+            "progress_started", partial(ProgressHandler._signal, "started")
+        )
+        self.bridge.register_signal(
+            "progress_finished", partial(ProgressHandler._signal, "finished")
+        )
+        self.bridge.register_signal(
+            "progress_error", partial(ProgressHandler._signal, "error")
+        )
+
+        # media dirs
+        # FIXME: get rid of dirname and "/" in C.XXX_DIR
+        self.put_child_all(os.path.dirname(C.MEDIA_DIR).encode('utf-8'),
+                         ProtectedFile(self.media_dir))
+
+        self.cache_resource = web_resource.NoResource()
+        self.put_child_all(C.CACHE_DIR.encode('utf-8'), self.cache_resource)
+        self.cache_resource.putChild(
+            b"common", ProtectedFile(str(self.cache_root_dir / Path("common"))))
+
+        # redirections
+        for root in self.roots:
+            await root._init_redirections(self.options)
+
+        # no need to keep url_redirections_dict, it will not be used anymore
+        del self.options["url_redirections_dict"]
+
+        server.Request.defaultContentType = "text/html; charset=utf-8"
+        wrapped = web_resource.EncodingResourceWrapper(
+            self.vhost_root, [server.GzipEncoderFactory()]
+        )
+        self.site = server.Site(wrapped)
+        self.site.sessionFactory = WebSession
+
+    def _bridge_cb(self):
+        del self._bridge_retry
+        self.bridge.ready_get(
+            lambda: self.initialised.callback(None),
+            lambda failure: self.initialised.errback(Exception(failure)),
+        )
+        self.initialised.addCallback(lambda __: defer.ensureDeferred(self.backend_ready()))
+
+    def _bridge_eb(self, failure_):
+        if isinstance(failure_, BridgeExceptionNoService):
+            if self._bridge_retry:
+                if self._bridge_retry < 0:
+                    print(_("Can't connect to bridge, will retry indefinitely. "
+                            "Next try in 1s."))
+                else:
+                    self._bridge_retry -= 1
+                    print(
+                        _(
+                            "Can't connect to bridge, will retry in 1 s ({retries_left} "
+                            "trie(s) left)."
+                        ).format(retries_left=self._bridge_retry)
+                    )
+                time.sleep(1)
+                self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb)
+                return
+
+            print("Can't connect to SàT backend, are you sure it's launched ?")
+        else:
+            log.error("Can't connect to bridge: {}".format(failure))
+        sys.exit(1)
+
+    @property
+    def version(self):
+        """Return the short version of Libervia"""
+        return C.APP_VERSION
+
+    @property
+    def full_version(self):
+        """Return the full version of Libervia (with extra data when in dev mode)"""
+        version = self.version
+        if version[-1] == "D":
+            # we are in debug version, we add extra data
+            try:
+                return self._version_cache
+            except AttributeError:
+                self._version_cache = "{} ({})".format(
+                    version, utils.get_repository_data(libervia.web)
+                )
+                return self._version_cache
+        else:
+            return version
+
+    def bridge_call(self, method_name, *args, **kwargs):
+        """Call an asynchronous bridge method and return a deferred
+
+        @param method_name: name of the method as a unicode
+        @return: a deferred which trigger the result
+
+        """
+        d = defer.Deferred()
+
+        def _callback(*args):
+            if not args:
+                d.callback(None)
+            else:
+                if len(args) != 1:
+                    Exception("Multiple return arguments not supported")
+                d.callback(args[0])
+
+        def _errback(failure_):
+            d.errback(failure.Failure(failure_))
+
+        kwargs["callback"] = _callback
+        kwargs["errback"] = _errback
+        getattr(self.bridge, method_name)(*args, **kwargs)
+        return d
+
+    def action_new_handler(
+        self,
+        action_data_s: str,
+        action_id: str,
+        security_limit: int,
+        profile: str
+    ) -> None:
+        if security_limit > C.SECURITY_LIMIT:
+            log.debug(
+                f"ignoring action {action_id} due to security limit"
+            )
+        else:
+            self.on_signal(
+                "action_new", action_data_s, action_id, security_limit, profile
+            )
+
+    def on_signal(self, signal_name, *args):
+        profile = args[-1]
+        if not profile:
+            log.error(f"got signal without profile: {signal_name}, {args}")
+            return
+        session_iface.WebSession.send(
+            profile,
+            "bridge",
+            {"signal": signal_name, "args": args}
+        )
+
+    def application_started_handler(
+        self,
+        name: str,
+        instance_id: str,
+        extra_s: str
+    ) -> None:
+        callback = self.apps_cb.pop(instance_id, None)
+        if callback is not None:
+            defer.ensureDeferred(callback(str(name), str(instance_id)))
+
+    def application_error_handler(
+        self,
+        name: str,
+        instance_id: str,
+        extra_s: str
+    ) -> None:
+        callback = self.apps_cb.pop(instance_id, None)
+        if callback is not None:
+            extra = data_format.deserialise(extra_s)
+            log.error(
+                f"Can't start application {name}: {extra['class']}\n{extra['msg']}"
+            )
+
+    async def _logged(self, profile, request):
+        """Set everything when a user just logged in
+
+        @param profile
+        @param request
+        @return: a constant indicating the state:
+            - C.PROFILE_LOGGED
+            - C.PROFILE_LOGGED_EXT_JID
+        @raise exceptions.ConflictError: session is already active
+        """
+        register_with_ext_jid = self.waiting_profiles.get_register_with_ext_jid(profile)
+        self.waiting_profiles.purge_request(profile)
+        session = request.getSession()
+        web_session = session_iface.IWebSession(session)
+        if web_session.profile:
+            log.error(_("/!\\ Session has already a profile, this should NEVER happen!"))
+            raise failure.Failure(exceptions.ConflictError("Already active"))
+
+        # XXX: we force string because python D-Bus has its own string type (dbus.String)
+        #   which may cause trouble when exposing it to scripts
+        web_session.profile = str(profile)
+        self.prof_connected.add(profile)
+        cache_dir = os.path.join(
+            self.cache_root_dir, "profiles", regex.path_escape(profile)
+        )
+        # FIXME: would be better to have a global /cache URL which redirect to
+        #        profile's cache directory, without uuid
+        self.cache_resource.putChild(web_session.uuid.encode('utf-8'),
+                                     ProtectedFile(cache_dir))
+        log.debug(
+            _("profile cache resource added from {uuid} to {path}").format(
+                uuid=web_session.uuid, path=cache_dir
+            )
+        )
+
+        def on_expire():
+            log.info("Session expired (profile={profile})".format(profile=profile))
+            self.cache_resource.delEntity(web_session.uuid.encode('utf-8'))
+            log.debug(
+                _("profile cache resource {uuid} deleted").format(uuid=web_session.uuid)
+            )
+            web_session.on_expire()
+            if web_session.ws_socket is not None:
+                web_session.ws_socket.close()
+            # and now we disconnect the profile
+            self.bridge_call("disconnect", profile)
+
+        session.notifyOnExpire(on_expire)
+
+        # FIXME: those session infos should be returned by connect or is_connected
+        infos = await self.bridge_call("session_infos_get", profile)
+        web_session.jid = jid.JID(infos["jid"])
+        own_bare_jid_s = web_session.jid.userhost()
+        own_id_raw = await self.bridge_call(
+            "identity_get", own_bare_jid_s, [], True, profile)
+        web_session.identities[own_bare_jid_s] = data_format.deserialise(own_id_raw)
+        web_session.backend_started = int(infos["started"])
+
+        state = C.PROFILE_LOGGED_EXT_JID if register_with_ext_jid else C.PROFILE_LOGGED
+        return state
+
+    @defer.inlineCallbacks
+    def connect(self, request, login, password):
+        """log user in
+
+        If an other user was already logged, it will be unlogged first
+        @param request(server.Request): request linked to the session
+        @param login(unicode): user login
+            can be profile name
+            can be profile@[libervia_domain.ext]
+            can be a jid (a new profile will be created with this jid if needed)
+        @param password(unicode): user password
+        @return (unicode, None): C.SESSION_ACTIVE: if session was aleady active else
+            self._logged value
+        @raise exceptions.DataError: invalid login
+        @raise exceptions.ProfileUnknownError: this login doesn't exist
+        @raise exceptions.PermissionError: a login is not accepted (e.g. empty password
+            not allowed)
+        @raise exceptions.NotReady: a profile connection is already waiting
+        @raise exceptions.TimeoutError: didn't received and answer from bridge
+        @raise exceptions.InternalError: unknown error
+        @raise ValueError(C.PROFILE_AUTH_ERROR): invalid login and/or password
+        @raise ValueError(C.XMPP_AUTH_ERROR): invalid XMPP account password
+        """
+
+        # XXX: all security checks must be done here, even if present in javascript
+        if login.startswith("@"):
+            raise failure.Failure(exceptions.DataError("No profile_key allowed"))
+
+        if login.startswith("guest@@") and login.count("@") == 2:
+            log.debug("logging a guest account")
+        elif "@" in login:
+            if login.count("@") != 1:
+                raise failure.Failure(
+                    exceptions.DataError("Invalid login: {login}".format(login=login))
+                )
+            try:
+                login_jid = jid.JID(login)
+            except (RuntimeError, jid.InvalidFormat, AttributeError):
+                raise failure.Failure(exceptions.DataError("No profile_key allowed"))
+
+            # FIXME: should it be cached?
+            new_account_domain = yield self.bridge_call("account_domain_new_get")
+
+            if login_jid.host == new_account_domain:
+                # redirect "user@libervia.org" to the "user" profile
+                login = login_jid.user
+                login_jid = None
+        else:
+            login_jid = None
+
+        try:
+            profile = yield self.bridge_call("profile_name_get", login)
+        except Exception:  # XXX: ProfileUnknownError wouldn't work, it's encapsulated
+            # FIXME: find a better way to handle bridge errors
+            if (
+                login_jid is not None and login_jid.user
+            ):  # try to create a new libervia.backend profile using the XMPP credentials
+                if not self.options["allow_registration"]:
+                    log.warning(
+                        "Trying to register JID account while registration is not "
+                        "allowed")
+                    raise failure.Failure(
+                        exceptions.DataError(
+                            "JID login while registration is not allowed"
+                        )
+                    )
+                profile = login  # FIXME: what if there is a resource?
+                connect_method = "credentials_xmpp_connect"
+                register_with_ext_jid = True
+            else:  # non existing username
+                raise failure.Failure(exceptions.ProfileUnknownError())
+        else:
+            if profile != login or (
+                not password
+                and profile
+                not in self.options["empty_password_allowed_warning_dangerous_list"]
+            ):
+                # profiles with empty passwords are restricted to local frontends
+                raise exceptions.PermissionError
+            register_with_ext_jid = False
+
+            connect_method = "connect"
+
+        # we check if there is not already an active session
+        web_session = session_iface.IWebSession(request.getSession())
+        if web_session.profile:
+            # yes, there is
+            if web_session.profile != profile:
+                # it's a different profile, we need to disconnect it
+                log.warning(_(
+                    "{new_profile} requested login, but {old_profile} was already "
+                    "connected, disconnecting {old_profile}").format(
+                        old_profile=web_session.profile, new_profile=profile))
+                self.purge_session(request)
+
+        if self.waiting_profiles.get_request(profile):
+            #  FIXME: check if and when this can happen
+            raise failure.Failure(exceptions.NotReady("Already waiting"))
+
+        self.waiting_profiles.set_request(request, profile, register_with_ext_jid)
+        try:
+            connected = yield self.bridge_call(connect_method, profile, password)
+        except Exception as failure_:
+            fault = getattr(failure_, 'classname', None)
+            self.waiting_profiles.purge_request(profile)
+            if fault in ("PasswordError", "ProfileUnknownError"):
+                log.info("Profile {profile} doesn't exist or the submitted password is "
+                         "wrong".format( profile=profile))
+                raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR))
+            elif fault == "SASLAuthError":
+                log.info("The XMPP password of profile {profile} is wrong"
+                    .format(profile=profile))
+                raise failure.Failure(ValueError(C.XMPP_AUTH_ERROR))
+            elif fault == "NoReply":
+                log.info(_("Did not receive a reply (the timeout expired or the "
+                           "connection is broken)"))
+                raise exceptions.TimeOutError
+            elif fault is None:
+                log.info(_("Unexepected failure: {failure_}").format(failure_=failure))
+                raise failure_
+            else:
+                log.error('Unmanaged fault class "{fault}" in errback for the '
+                          'connection of profile {profile}'.format(
+                              fault=fault, profile=profile))
+                raise failure.Failure(exceptions.InternalError(fault))
+
+        if connected:
+            #  profile is already connected in backend
+            # do we have a corresponding session in Libervia?
+            web_session = session_iface.IWebSession(request.getSession())
+            if web_session.profile:
+                # yes, session is active
+                if web_session.profile != profile:
+                    # existing session should have been ended above
+                    # so this line should never be reached
+                    log.error(_(
+                        "session profile [{session_profile}] differs from login "
+                        "profile [{profile}], this should not happen!")
+                            .format(session_profile=web_session.profile, profile=profile))
+                    raise exceptions.InternalError("profile mismatch")
+                defer.returnValue(C.SESSION_ACTIVE)
+            log.info(
+                _(
+                    "profile {profile} was already connected in backend".format(
+                        profile=profile
+                    )
+                )
+            )
+            #  no, we have to create it
+
+        state = yield defer.ensureDeferred(self._logged(profile, request))
+        defer.returnValue(state)
+
+    def register_new_account(self, request, login, password, email):
+        """Create a new account, or return error
+        @param request(server.Request): request linked to the session
+        @param login(unicode): new account requested login
+        @param email(unicode): new account email
+        @param password(unicode): new account password
+        @return(unicode): a constant indicating the state:
+            - C.BAD_REQUEST: something is wrong in the request (bad arguments)
+            - C.INVALID_INPUT: one of the data is not valid
+            - C.REGISTRATION_SUCCEED: new account has been successfully registered
+            - C.ALREADY_EXISTS: the given profile already exists
+            - C.INTERNAL_ERROR or any unmanaged fault string
+        @raise PermissionError: registration is now allowed in server configuration
+        """
+        if not self.options["allow_registration"]:
+            log.warning(
+                _("Registration received while it is not allowed, hack attempt?")
+            )
+            raise failure.Failure(
+                exceptions.PermissionError("Registration is not allowed on this server")
+            )
+
+        if (
+            not re.match(C.REG_LOGIN_RE, login)
+            or not re.match(C.REG_EMAIL_RE, email, re.IGNORECASE)
+            or len(password) < C.PASSWORD_MIN_LENGTH
+        ):
+            return C.INVALID_INPUT
+
+        def registered(result):
+            return C.REGISTRATION_SUCCEED
+
+        def registering_error(failure_):
+            # FIXME: better error handling for bridge error is needed
+            status = failure_.value.fullname.split('.')[-1]
+            if status == "ConflictError":
+                return C.ALREADY_EXISTS
+            elif status == "InvalidCertificate":
+                return C.INVALID_CERTIFICATE
+            elif status == "InternalError":
+                return C.INTERNAL_ERROR
+            else:
+                log.error(
+                    _("Unknown registering error status: {status}\n{traceback}").format(
+                        status=status, traceback=failure_.value.message
+                    )
+                )
+                return status
+
+        d = self.bridge_call("libervia_account_register", email, password, login)
+        d.addCallback(registered)
+        d.addErrback(registering_error)
+        return d
+
+    def addCleanup(self, callback, *args, **kwargs):
+        """Add cleaning method to call when service is stopped
+
+        cleaning method will be called in reverse order of they insertion
+        @param callback: callable to call on service stop
+        @param *args: list of arguments of the callback
+        @param **kwargs: list of keyword arguments of the callback"""
+        self._cleanup.insert(0, (callback, args, kwargs))
+
+    def init_eb(self, failure):
+        from twisted.application import app
+        if failure.check(SysExit):
+            if failure.value.message:
+                log.error(failure.value.message)
+            app._exitCode = failure.value.exit_code
+            reactor.stop()
+        else:
+            log.error(_("Init error: {msg}").format(msg=failure))
+            app._exitCode = C.EXIT_INTERNAL_ERROR
+            reactor.stop()
+            return failure
+
+    def _build_only_cb(self, __):
+        log.info(_("Stopping here due to --build-only flag"))
+        self.stop()
+
+    def startService(self):
+        """Connect the profile for Libervia and start the HTTP(S) server(s)"""
+        self._init()
+        if self.options['build-only']:
+            self.initialised.addCallback(self._build_only_cb)
+        else:
+            self.initialised.addCallback(self._start_service)
+        self.initialised.addErrback(self.init_eb)
+
+    ## URLs ##
+
+    def put_child_sat(self, path, resource):
+        """Add a child to the libervia.backend resource"""
+        if not isinstance(path, bytes):
+            raise ValueError("path must be specified in bytes")
+        self.sat_root.putChild(path, resource)
+
+    def put_child_all(self, path, resource):
+        """Add a child to all vhost root resources"""
+        if not isinstance(path, bytes):
+            raise ValueError("path must be specified in bytes")
+        # we wrap before calling putChild, to avoid having useless multiple instances
+        # of the resource
+        # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders)
+        wrapped_res = web_resource.EncodingResourceWrapper(
+            resource, [server.GzipEncoderFactory()])
+        for root in self.roots:
+            root.putChild(path, wrapped_res)
+
+    def get_build_path(self, site_name: str, dev: bool=False) -> Path:
+        """Generate build path for a given site name
+
+        @param site_name: name of the site
+        @param dev: return dev build dir if True, production one otherwise
+            dev build dir is used for installing dependencies needed temporarily (e.g.
+            to compile files), while production build path is the one served by the
+            HTTP server, where final files are downloaded.
+        @return: path to the build directory
+        """
+        sub_dir = C.DEV_BUILD_DIR if dev else C.PRODUCTION_BUILD_DIR
+        build_path_elts = [
+            config.config_get(self.main_conf, "", "local_dir"),
+            C.CACHE_DIR,
+            C.LIBERVIA_CACHE,
+            sub_dir,
+            regex.path_escape(site_name or C.SITE_NAME_DEFAULT)]
+        build_path = Path("/".join(build_path_elts))
+        return build_path.expanduser().resolve()
+
+    def get_ext_base_url_data(self, request):
+        """Retrieve external base URL Data
+
+        this method try to retrieve the base URL found by external user
+        It does by checking in this order:
+            - base_url_ext option from configuration
+            - proxy x-forwarder-host headers
+            - URL of the request
+        @return (urlparse.SplitResult): SplitResult instance with only scheme and
+            netloc filled
+        """
+        ext_data = self.base_url_ext_data
+        url_path = request.URLPath()
+
+        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:
+            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(),
+            ext_data.netloc or proxy_netloc or url_path.netloc.decode(),
+            ext_data.path or "/",
+            "",
+            "",
+        )
+
+    def get_ext_base_url(
+            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: same as for urlsplit.urlsplit
+            path will be prefixed to follow found external URL if suitable
+        @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.get_ext_base_url_data(request)
+        return urllib.parse.urlunsplit(
+            (
+                split_result.scheme if scheme is None else scheme,
+                split_result.netloc,
+                os.path.join(split_result.path, path),
+                query,
+                fragment,
+            )
+        )
+
+    def check_redirection(self, vhost_root: LiberviaRootResource, url_path: str) -> str:
+        """check is a part of the URL prefix is redirected then replace it
+
+        @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_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_path
+
+    ## Sessions ##
+
+    def purge_session(self, request):
+        """helper method to purge a session during request handling"""
+        session = request.session
+        if session is not None:
+            log.debug(_("session purge"))
+            web_session = self.get_session_data(request, session_iface.IWebSession)
+            socket = web_session.ws_socket
+            if socket is not None:
+                socket.close()
+                session.ws_socket = None
+            session.expire()
+            # FIXME: not clean but it seems that it's the best way to reset
+            #        session during request handling
+            request._secureSession = request._insecureSession = None
+
+    def get_session_data(self, request, *args):
+        """helper method to retrieve session data
+
+        @param request(server.Request): request linked to the session
+        @param *args(zope.interface.Interface): interface of the session to get
+        @return (iterator(data)): requested session data
+        """
+        session = request.getSession()
+        if len(args) == 1:
+            return args[0](session)
+        else:
+            return (iface(session) for iface in args)
+
+    @defer.inlineCallbacks
+    def get_affiliation(self, request, service, node):
+        """retrieve pubsub node affiliation for current user
+
+        use cache first, and request pubsub service if not cache is found
+        @param request(server.Request): request linked to the session
+        @param service(jid.JID): pubsub service
+        @param node(unicode): pubsub node
+        @return (unicode): affiliation
+        """
+        web_session = self.get_session_data(request, session_iface.IWebSession)
+        if web_session.profile is None:
+            raise exceptions.InternalError("profile must be set to use this method")
+        affiliation = web_session.get_affiliation(service, node)
+        if affiliation is not None:
+            defer.returnValue(affiliation)
+        else:
+            try:
+                affiliations = yield self.bridge_call(
+                    "ps_affiliations_get", service.full(), node, web_session.profile
+                )
+            except Exception as e:
+                log.warning(
+                    "Can't retrieve affiliation for {service}/{node}: {reason}".format(
+                        service=service, node=node, reason=e
+                    )
+                )
+                affiliation = ""
+            else:
+                try:
+                    affiliation = affiliations[node]
+                except KeyError:
+                    affiliation = ""
+            web_session.set_affiliation(service, node, affiliation)
+            defer.returnValue(affiliation)
+
+    ## Websocket (dynamic pages) ##
+
+    def get_websocket_url(self, request):
+        base_url_split = self.get_ext_base_url_data(request)
+        if base_url_split.scheme.endswith("s"):
+            scheme = "wss"
+        else:
+            scheme = "ws"
+
+        return self.get_ext_base_url(request, path=scheme, scheme=scheme)
+
+
+    ## Various utils ##
+
+    def get_http_date(self, timestamp=None):
+        now = time.gmtime(timestamp)
+        fmt_date = "{day_name}, %d {month_name} %Y %H:%M:%S GMT".format(
+            day_name=C.HTTP_DAYS[now.tm_wday], month_name=C.HTTP_MONTH[now.tm_mon - 1]
+        )
+        return time.strftime(fmt_date, now)
+
+    ## service management ##
+
+    def _start_service(self, __=None):
+        """Actually start the HTTP(S) server(s) after the profile for Libervia is connected.
+
+        @raise ImportError: OpenSSL is not available
+        @raise IOError: the certificate file doesn't exist
+        @raise OpenSSL.crypto.Error: the certificate file is invalid
+        """
+        # now that we have service profile connected, we add resource for its cache
+        service_path = regex.path_escape(C.SERVICE_PROFILE)
+        cache_dir = os.path.join(self.cache_root_dir, "profiles", service_path)
+        self.cache_resource.putChild(service_path.encode('utf-8'),
+                                     ProtectedFile(cache_dir))
+        self.service_cache_url = "/" + os.path.join(C.CACHE_DIR, service_path)
+        session_iface.WebSession.service_cache_url = self.service_cache_url
+
+        if self.options["connection_type"] in ("https", "both"):
+            try:
+                tls.tls_options_check(self.options)
+                context_factory = tls.get_tls_context_factory(self.options)
+            except exceptions.ConfigError as e:
+                log.warning(
+                    f"There is a problem in TLS settings in your configuration file: {e}")
+                self.quit(2)
+            except exceptions.DataError as e:
+                log.warning(
+                    f"Can't set TLS: {e}")
+                self.quit(1)
+            reactor.listenSSL(self.options["port_https"], self.site, context_factory)
+        if self.options["connection_type"] in ("http", "both"):
+            if (
+                self.options["connection_type"] == "both"
+                and self.options["redirect_to_https"]
+            ):
+                reactor.listenTCP(
+                    self.options["port"],
+                    server.Site(
+                        RedirectToHTTPS(
+                            self.options["port"], self.options["port_https_ext"]
+                        )
+                    ),
+                )
+            else:
+                reactor.listenTCP(self.options["port"], self.site)
+
+    @defer.inlineCallbacks
+    def stopService(self):
+        log.info(_("launching cleaning methods"))
+        for callback, args, kwargs in self._cleanup:
+            callback(*args, **kwargs)
+        try:
+            yield self.bridge_call("disconnect", C.SERVICE_PROFILE)
+        except Exception:
+            log.warning("Can't disconnect service profile")
+
+    def run(self):
+        reactor.run()
+
+    def stop(self):
+        reactor.stop()
+
+    def quit(self, exit_code=None):
+        """Exit app when reactor is running
+
+        @param exit_code(None, int): exit code
+        """
+        self.stop()
+        sys.exit(exit_code or 0)
+
+
+class RedirectToHTTPS(web_resource.Resource):
+    def __init__(self, old_port, new_port):
+        web_resource.Resource.__init__(self)
+        self.isLeaf = True
+        self.old_port = old_port
+        self.new_port = new_port
+
+    def render(self, request):
+        netloc = request.URLPath().netloc.decode().replace(
+            f":{self.old_port}", f":{self.new_port}"
+        )
+        url = f"https://{netloc}{request.uri.decode()}"
+        return web_util.redirectTo(url.encode(), request)
+
+
+registerAdapter(session_iface.WebSession, server.Session, session_iface.IWebSession)
+registerAdapter(
+    session_iface.WebGuestSession, server.Session, session_iface.IWebGuestSession
+)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/session_iface.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,289 @@
+#!/usr/bin/env python3
+
+# Libervia: a SàT frontend
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from collections import OrderedDict, deque
+import os.path
+import time
+from typing import List, Dict, Optional
+
+import shortuuid
+from zope.interface import Attribute, Interface
+from zope.interface import implementer
+
+from libervia.backend.core.log import getLogger
+
+from libervia.web.server.classes import Notification
+from libervia.web.server.constants import Const as C
+
+
+log = getLogger(__name__)
+
+FLAGS_KEY = "_flags"
+NOTIFICATIONS_KEY = "_notifications"
+MAX_CACHE_AFFILIATIONS = 100  # number of nodes to keep in cache
+
+
+class IWebSession(Interface):
+    profile = Attribute("Sat profile")
+    jid = Attribute("JID associated with the profile")
+    uuid = Attribute("uuid associated with the profile session")
+    identities = Attribute("Identities of XMPP entities")
+
+
+@implementer(IWebSession)
+class WebSession:
+    profiles_map: Dict[Optional[str], List["WebSession"]] = {}
+
+    def __init__(self, session):
+        self._profile = None
+        self.jid = None
+        self.started = time.time()
+        # time when the backend session was started
+        self.backend_started = None
+        self.uuid = str(shortuuid.uuid())
+        self.identities = {}
+        self.csrf_token = str(shortuuid.uuid())
+        self.ws_token = str(shortuuid.uuid())
+        self.ws_socket = None
+        self.ws_buffer = deque(maxlen=200)
+        self.locale = None  # i18n of the pages
+        self.theme = None
+        self.pages_data = {}  # used to keep data accross reloads (key is page instance)
+        self.affiliations = OrderedDict()  # cache for node affiliations
+        self.profiles_map.setdefault(C.SERVICE_PROFILE, []).append(self)
+        log.debug(
+            f"session started for {C.SERVICE_PROFILE} "
+            f"({len(self.get_profile_sessions(C.SERVICE_PROFILE))} session(s) active)"
+        )
+
+    @property
+    def profile(self) -> Optional[str]:
+        return self._profile
+
+    @profile.setter
+    def profile(self, profile: Optional[str]) -> None:
+        old_profile = self._profile or C.SERVICE_PROFILE
+        new_profile = profile or C.SERVICE_PROFILE
+        try:
+            self.profiles_map[old_profile].remove(self)
+        except (ValueError, KeyError):
+            log.warning(f"session was not registered for profile {old_profile}")
+        else:
+            nb_session_old = len(self.get_profile_sessions(old_profile))
+            log.debug(f"{old_profile} has now {nb_session_old} session(s) active")
+
+        self._profile = profile
+        self.profiles_map.setdefault(new_profile, []).append(self)
+        nb_session_new = len(self.get_profile_sessions(new_profile))
+        log.debug(f"{new_profile} has now {nb_session_new} session(s) active")
+
+    @property
+    def cache_dir(self):
+        if self.profile is None:
+            return self.service_cache_url + "/"
+        return os.path.join("/", C.CACHE_DIR, self.uuid) + "/"
+
+    @property
+    def connected(self):
+        return self.profile is not None
+
+    @property
+    def guest(self):
+        """True if this is a guest session"""
+        if self.profile is None:
+            return False
+        else:
+            return self.profile.startswith("guest@@")
+
+    @classmethod
+    def send(cls, profile: str, data_type: str, data: dict) -> None:
+        """Send a message to all session
+
+        If the session doesn't have an active websocket, the message is buffered until a
+        socket is available
+        """
+        for session in cls.profiles_map.get(profile, []):
+            if session.ws_socket is None or not session.ws_socket.init_ok:
+                session.ws_buffer.append({"data_type": data_type, "data": data})
+            else:
+                session.ws_socket.send(data_type, data)
+
+    def on_expire(self) -> None:
+        profile = self._profile or C.SERVICE_PROFILE
+        try:
+            self.profiles_map[profile].remove(self)
+        except (ValueError, KeyError):
+            log.warning(f"session was not registered for profile {profile}")
+        else:
+            nb_session = len(self.get_profile_sessions(profile))
+            log.debug(
+                f"Session for profile {profile} expired. {profile} has now {nb_session} "
+                f"session(s) active."
+            )
+
+    @classmethod
+    def get_profile_sessions(cls, profile: str) -> List["WebSession"]:
+        return cls.profiles_map.get(profile, [])
+
+    def get_page_data(self, page, key):
+        """get session data for a page
+
+        @param page(LiberviaPage): instance of the page
+        @param key(object): data key
+        return (None, object): value of the key
+            None if not found or page_data doesn't exist
+        """
+        return self.pages_data.get(page, {}).get(key)
+
+    def pop_page_data(self, page, key, default=None):
+        """like get_page_data, but remove key once value is gotten
+
+        @param page(LiberviaPage): instance of the page
+        @param key(object): data key
+        @param default(object): value to return if key is not found
+        @return (object): found value or default
+        """
+        page_data = self.pages_data.get(page)
+        if page_data is None:
+            return default
+        value = page_data.pop(key, default)
+        if not page_data:
+            # no need to keep unused page_data
+            del self.pages_data[page]
+        return value
+
+    def set_page_data(self, page, key, value):
+        """set data to persist on reload
+
+        @param page(LiberviaPage): instance of the page
+        @param key(object): data key
+        @param value(object): value to set
+        @return (object): set value
+        """
+        page_data = self.pages_data.setdefault(page, {})
+        page_data[key] = value
+        return value
+
+    def set_page_flag(self, page, flag):
+        """set a flag for this page
+
+        @param page(LiberviaPage): instance of the page
+        @param flag(unicode): flag to set
+        """
+        flags = self.get_page_data(page, FLAGS_KEY)
+        if flags is None:
+            flags = self.set_page_data(page, FLAGS_KEY, set())
+        flags.add(flag)
+
+    def pop_page_flag(self, page, flag):
+        """return True if flag is set
+
+        flag is removed if it was set
+        @param page(LiberviaPage): instance of the page
+        @param flag(unicode): flag to set
+        @return (bool): True if flaag was set
+        """
+        page_data = self.pages_data.get(page, {})
+        flags = page_data.get(FLAGS_KEY)
+        if flags is None:
+            return False
+        if flag in flags:
+            flags.remove(flag)
+            # we remove data if they are not used anymore
+            if not flags:
+                del page_data[FLAGS_KEY]
+            if not page_data:
+                del self.pages_data[page]
+            return True
+        else:
+            return False
+
+    def set_page_notification(self, page, message, level=C.LVL_INFO):
+        """set a flag for this page
+
+        @param page(LiberviaPage): instance of the page
+        @param flag(unicode): flag to set
+        """
+        notif = Notification(message, level)
+        notifs = self.get_page_data(page, NOTIFICATIONS_KEY)
+        if notifs is None:
+            notifs = self.set_page_data(page, NOTIFICATIONS_KEY, [])
+        notifs.append(notif)
+
+    def pop_page_notifications(self, page):
+        """Return and remove last page notification
+
+        @param page(LiberviaPage): instance of the page
+        @return (list[Notification]): notifications if any
+        """
+        page_data = self.pages_data.get(page, {})
+        notifs = page_data.get(NOTIFICATIONS_KEY)
+        if not notifs:
+            return []
+        ret = notifs[:]
+        del notifs[:]
+        return ret
+
+    def get_affiliation(self, service, node):
+        """retrieve affiliation for a pubsub node
+
+        @param service(jid.JID): pubsub service
+        @param node(unicode): pubsub node
+        @return (unicode, None): affiliation, or None if it is not in cache
+        """
+        if service.resource:
+            raise ValueError("Service must not have a resource")
+        if not node:
+            raise ValueError("node must be set")
+        try:
+            affiliation = self.affiliations.pop((service, node))
+        except KeyError:
+            return None
+        else:
+            # we replace at the top to get the most recently used on top
+            # so less recently used will be removed if cache is full
+            self.affiliations[(service, node)] = affiliation
+            return affiliation
+
+    def set_affiliation(self, service, node, affiliation):
+        """cache affiliation for a node
+
+        will empty cache when it become too big
+        @param service(jid.JID): pubsub service
+        @param node(unicode): pubsub node
+        @param affiliation(unicode): affiliation to this node
+        """
+        if service.resource:
+            raise ValueError("Service must not have a resource")
+        if not node:
+            raise ValueError("node must be set")
+        self.affiliations[(service, node)] = affiliation
+        while len(self.affiliations) > MAX_CACHE_AFFILIATIONS:
+            self.affiliations.popitem(last=False)
+
+
+class IWebGuestSession(Interface):
+    id = Attribute("UUID of the guest")
+    data = Attribute("data associated with the guest")
+
+
+@implementer(IWebGuestSession)
+class WebGuestSession(object):
+
+    def __init__(self, session):
+        self.id = None
+        self.data = None
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/tasks/implicit/task_brython.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,149 @@
+#!/ur/bin/env python3
+
+from ast import literal_eval
+import json
+from pathlib import Path
+import shutil
+from typing import Any, Dict
+
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import utils
+
+from libervia.web.server.classes import Script
+from libervia.web.server.constants import Const as C
+from libervia.web.server.tasks import task
+
+
+log = getLogger(__name__)
+
+
+class Task(task.Task):
+
+    def prepare(self):
+        if "brython" not in self.resource.browser_modules:
+            raise exceptions.CancelError("No brython module found")
+
+        brython_js = self.build_path / "brython.js"
+        if not brython_js.is_file():
+            installed_ver = None
+        else:
+            with brython_js.open() as f:
+                for line in f:
+                    if line.startswith('// implementation ['):
+                        installed_ver = literal_eval(line[18:])[:3]
+                        log.debug(
+                            f"brython v{'.'.join(str(v) for v in installed_ver)} already "
+                            f"installed")
+                        break
+                else:
+                    log.warning(
+                        f"brython file at {brython_js} doesn't has implementation "
+                        f"version"
+                    )
+                    installed_ver = None
+
+        try:
+            import brython
+            try:
+                from brython.__main__ import implementation
+            except ImportError:
+                from brython.version import implementation
+        except ModuleNotFoundError as e:
+            log.error('"brython" module is missing, can\'t use browser code for Brython')
+            raise e
+        ver = [int(v) for v in implementation.split('.')[:3]]
+        if ver != installed_ver:
+            log.info(_("Installing Brython v{version}").format(
+                version='.'.join(str(v) for v in ver)))
+            data_path = Path(brython.__file__).parent / 'data'
+            # shutil has blocking method, but the task is run before we start
+            # the web server, so it's not a big deal
+            shutil.copyfile(data_path / "brython.js", brython_js)
+            shutil.copy(data_path / "brython_stdlib.js", self.build_path)
+        else:
+            log.debug("Brython is already installed")
+
+        self.WATCH_DIRS = []
+        self.set_common_scripts()
+
+    def set_common_scripts(self):
+        for dyn_data in self.resource.browser_modules["brython"]:
+            url_hash = dyn_data['url_hash']
+            import_url = f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}"
+            dyn_data.setdefault('scripts', utils.OrderedSet()).update([
+                Script(src=f"/{C.BUILD_DIR}/brython.js"),
+                Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"),
+            ])
+            dyn_data.setdefault('template', {})['body_onload'] = self.get_body_onload(
+                extra_path=[import_url])
+            self.WATCH_DIRS.append(dyn_data['path'].resolve())
+
+    def get_body_onload(self, debug=True, cache=True, extra_path=None):
+        on_load_opts: Dict[str, Any]  = {"pythonpath": [f"/{C.BUILD_DIR}"]}
+        if debug:
+            on_load_opts["debug"] = 1
+        if cache:
+            on_load_opts["cache"] = True
+        if extra_path is not None:
+            on_load_opts["pythonpath"].extend(extra_path)
+
+        return f"brython({json.dumps(on_load_opts)})"
+
+    def copy_files(self, files_paths, dest):
+        for p in files_paths:
+            log.debug(f"copying {p}")
+            if p.is_dir():
+                if p.name == '__pycache__':
+                    continue
+                shutil.copytree(p, dest / p.name)
+            else:
+                shutil.copy(p, dest)
+
+    async def on_dir_event(self, host, filepath, flags):
+        self.set_common_scripts()
+        await self.manager.run_task_instance(self)
+
+    def start(self):
+        dyn_path = self.build_path / C.BUILD_DIR_DYN
+        for dyn_data in self.resource.browser_modules["brython"]:
+            url_hash = dyn_data['url_hash']
+            if url_hash is None:
+                # root modules
+                url_prefix = dyn_data.get('url_prefix')
+                if url_prefix is None:
+                    dest = self.build_path
+                    init_dest_url = f"/{C.BUILD_DIR}/__init__.py"
+                else:
+                    dest = self.build_path / url_prefix
+                    dest.mkdir(exist_ok = True)
+                    init_dest_url = f"/{C.BUILD_DIR}/{url_prefix}/__init__.py"
+
+                self.copy_files(dyn_data['path'].glob('*py'), dest)
+
+                init_file = dyn_data['path'] / '__init__.py'
+                if init_file.is_file():
+                    self.resource.dyn_data_common['scripts'].update([
+                        Script(src=f"/{C.BUILD_DIR}/brython.js"),
+                        Script(src=f"/{C.BUILD_DIR}/brython_stdlib.js"),
+                        Script(type='text/python', src=init_dest_url)
+                    ])
+                    self.resource.dyn_data_common.setdefault(
+                        "template", {})['body_onload'] = self.get_body_onload()
+            else:
+                page_dyn_path = dyn_path / url_hash
+                log.debug(f"using dynamic path at {page_dyn_path}")
+                if page_dyn_path.exists():
+                    log.debug("cleaning existing path")
+                    shutil.rmtree(page_dyn_path)
+
+                page_dyn_path.mkdir(parents=True, exist_ok=True)
+                log.debug("copying browser python files")
+                self.copy_files(dyn_data['path'].iterdir(), page_dyn_path)
+
+                script = Script(
+                    type='text/python',
+                    src=f"/{C.BUILD_DIR}/{C.BUILD_DIR_DYN}/{url_hash}/__init__.py"
+                )
+                dyn_data.setdefault('scripts', utils.OrderedSet()).add(script)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/tasks/implicit/task_js_modules.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,69 @@
+#!/ur/bin/env python3
+
+import json
+from pathlib import Path
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.web.server.constants import Const as C
+from libervia.web.server.tasks import task
+
+
+log = getLogger(__name__)
+
+
+class Task(task.Task):
+
+    async def prepare(self):
+        if "js" not in self.resource.browser_modules:
+            raise exceptions.CancelError("No JS module needed")
+
+    async def start(self):
+        js_data = self.resource.browser_modules['js']
+        package = js_data.get('package', {})
+        package_path = self.build_path / 'package.json'
+        with package_path.open('w') as f:
+            json.dump(package, f)
+
+        cmd = self.find_command('yarnpkg', 'yarn')
+        await self.runCommand(cmd, 'install', path=str(self.build_path))
+
+        try:
+            brython_map = js_data['brython_map']
+        except KeyError:
+            pass
+        else:
+            log.info(_("creating JS modules mapping for Brython"))
+            js_modules_path = self.build_path / 'js_modules'
+            js_modules_path.mkdir(exist_ok=True)
+            init_path = js_modules_path / '__init__.py'
+            init_path.touch()
+
+            for module_name, module_data in brython_map.items():
+                log.debug(f"generating mapping for {module_name}")
+                if ' ' in module_name:
+                    raise ValueError(
+                        f"module {module_name!r} has space(s), it must not!")
+                module_path = js_modules_path / f"{module_name}.py"
+                if isinstance(module_data, str):
+                    module_data = {'path': module_data}
+                try:
+                    js_path = module_data.pop('path')
+                except KeyError:
+                    raise ValueError(
+                        f'module data for {module_name} must have a "path" key')
+                module_data['path'] = Path('node_modules') / js_path.strip(' /')
+                export = module_data.get('export') or [module_name]
+                export_objects = '\n'.join(f'{e} = window.{e}' for e in export)
+                extra_kwargs = {"build_dir": C.BUILD_DIR}
+
+                with module_path.open('w') as f:
+                    f.write(f"""\
+#!/usr/bin/env python3
+from browser import window, load
+{module_data.get('extra_import', '')}
+
+load("{Path('/').joinpath(C.BUILD_DIR, module_data['path'])}")
+{export_objects}
+{module_data.get('extra_init', '').format(**extra_kwargs)}
+""")
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/tasks/implicit/task_sass.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,69 @@
+#!/ur/bin/env python3
+
+import json
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.web.server.tasks import task
+
+
+log = getLogger(__name__)
+
+SASS_SUFFIXES = ('.sass', '.scss')
+
+
+class Task(task.Task):
+    """Compile .sass and .scss files found in themes browser paths"""
+    AFTER = ['js_modules']
+
+    async def prepare(self):
+        # we look for any Sass file, and cancel this task if none is found
+        sass_dirs = set()
+        for browser_path in self.resource.browser_modules.get('themes_browser_paths', []):
+            for p in browser_path.iterdir():
+                if p.suffix in SASS_SUFFIXES:
+                    sass_dirs.add(browser_path)
+                    break
+
+        if not sass_dirs:
+            raise exceptions.CancelError("No Sass file found")
+
+        # we have some Sass files, we need to install the compiler
+        d_path = self.resource.dev_build_path
+        package_path = d_path / "package.json"
+        try:
+            with package_path.open() as f:
+                package = json.load(f)
+        except FileNotFoundError:
+            package = {}
+        except Exception as e:
+            log.error(f"Unexepected exception while parsing package.json: {e}")
+
+        if 'node-sass' not in package.setdefault('dependencies', {}):
+            package['dependencies']['node-sass'] = 'latest'
+            with package_path.open('w') as f:
+                json.dump(package, f, indent=4)
+
+        cmd = self.find_command('yarnpkg', 'yarn')
+        await self.runCommand(cmd, 'install', path=str(d_path))
+
+        self.WATCH_DIRS = list(sass_dirs)
+
+    async def on_dir_event(self, host, filepath, flags):
+        if filepath.suffix in SASS_SUFFIXES:
+            await self.manager.run_task_instance(self)
+
+    async def start(self):
+        d_path = self.resource.dev_build_path
+        node_sass = d_path / 'node_modules' / 'node-sass' / 'bin' / 'node-sass'
+        for browser_path in self.resource.browser_modules['themes_browser_paths']:
+            for p in browser_path.iterdir():
+                if p.suffix not in SASS_SUFFIXES:
+                    continue
+                await self.runCommand(
+                    str(node_sass),
+                    "--omit-source-map-url",
+                    "--output-style", "compressed",
+                    "--output", str(self.build_path),
+                    str(p),
+                    path=str(self.build_path)
+                )
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/tasks/manager.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,213 @@
+#!/usr/bin/env python3
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+import os
+import os.path
+from pathlib import Path
+from typing import Dict
+import importlib.util
+from twisted.internet import defer
+from libervia.backend.core.log import getLogger
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import utils
+from libervia.web.server.constants import Const as C
+from . import implicit
+from .task import Task
+
+log = getLogger(__name__)
+
+DEFAULT_SITE_LABEL = _("default site")
+
+
+class TasksManager:
+    """Handle tasks of a Libervia site"""
+
+    def __init__(self, host, site_resource):
+        """
+        @param site_resource(LiberviaRootResource): root resource of the site to manage
+        """
+        self.host = host
+        self.resource = site_resource
+        self.tasks_dir = self.site_path / C.TASKS_DIR
+        self.tasks = {}
+        self._build_path = None
+        self._current_task = None
+
+    @property
+    def site_path(self):
+        return Path(self.resource.site_path)
+
+    @property
+    def build_path(self):
+        """path where generated files will be build for this site"""
+        if self._build_path is None:
+            self._build_path = self.host.get_build_path(self.site_name)
+        return self._build_path
+
+    @property
+    def site_name(self):
+        return self.resource.site_name
+
+    def validate_data(self, task):
+        """Check workflow attributes in task"""
+
+        for var, allowed in (("ON_ERROR", ("continue", "stop")),
+                             ("LOG_OUTPUT", bool),
+                             ("WATCH_DIRS", list)):
+            value = getattr(task, var)
+
+            if isinstance(allowed, type):
+                if allowed is list and value is None:
+                    continue
+                if not isinstance(value, allowed):
+                    raise ValueError(
+                        _("Unexpected value for {var}, {allowed} is expected.")
+                        .format(var=var, allowed=allowed))
+            else:
+                if not value in allowed:
+                    raise ValueError(_("Unexpected value for {var}: {value!r}").format(
+                        var=var, value=value))
+
+    async def import_task(
+        self,
+        task_name: str,
+        task_path: Path,
+        to_import: Dict[str, Path]
+    ) -> None:
+        if task_name in self.tasks:
+            log.debug(f"skipping task {task_name} which is already imported")
+            return
+        module_name = f"{self.site_name or C.SITE_NAME_DEFAULT}.task.{task_name}"
+
+        spec = importlib.util.spec_from_file_location(module_name, task_path)
+        task_module = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(task_module)
+        task = task_module.Task(self, task_name)
+        if task.AFTER is not None:
+            for pre_task_name in task.AFTER:
+                log.debug(
+                    f"task {task_name!r} must be run after {pre_task_name!r}")
+                try:
+                    pre_task_path = to_import[pre_task_name]
+                except KeyError:
+                    raise ValueError(
+                        f"task {task_name!r} must be run after {pre_task_name!r}, "
+                        f"however there is no task with such name")
+                await self.import_task(pre_task_name, pre_task_path, to_import)
+
+        # we launch prepare, which is a method used to prepare
+        # data at runtime (e.g. set WATCH_DIRS using config)
+        try:
+            prepare = task.prepare
+        except AttributeError:
+            pass
+        else:
+            log.info(_('== preparing task "{task_name}" for {site_name} =='.format(
+                task_name=task_name, site_name=self.site_name or DEFAULT_SITE_LABEL)))
+            try:
+                await utils.as_deferred(prepare)
+            except exceptions.CancelError as e:
+                log.debug(f"Skipping {task_name} which cancelled itself: {e}")
+                return
+
+        self.tasks[task_name] = task
+        self.validate_data(task)
+        if self.host.options['dev-mode']:
+            dirs = task.WATCH_DIRS or []
+            for dir_ in dirs:
+                self.host.files_watcher.watch_dir(
+                    dir_, auto_add=True, recursive=True,
+                    callback=self._autorun_task, task_name=task_name)
+
+    async def parse_tasks_dir(self, dir_path: Path) -> None:
+        log.debug(f"parsing tasks in {dir_path}")
+        tasks_paths = sorted(dir_path.glob('task_*.py'))
+        to_import = {}
+        for task_path in tasks_paths:
+            if not task_path.is_file():
+                continue
+            task_name = task_path.stem[5:].lower().strip()
+            if not task_name:
+                continue
+            if task_name in self.tasks:
+                raise exceptions.ConflictError(
+                    "A task with the name [{name}] already exists".format(
+                        name=task_name))
+            log.debug(f"task {task_name} found")
+            to_import[task_name] = task_path
+
+        for task_name, task_path in to_import.items():
+            await self.import_task(task_name, task_path, to_import)
+
+    async def parse_tasks(self):
+        # implicit tasks are always run
+        implicit_path = Path(implicit.__file__).parent
+        await self.parse_tasks_dir(implicit_path)
+        # now we check if there are tasks specific to this site
+        if not self.tasks_dir.is_dir():
+            log.debug(_("{name} has no task to launch.").format(
+                name = self.resource.site_name or DEFAULT_SITE_LABEL))
+            return
+        else:
+            await self.parse_tasks_dir(self.tasks_dir)
+
+    def _autorun_task(self, host, filepath, flags, task_name):
+        """Called when an event is received from a watched directory"""
+        if flags == ['create']:
+            return
+        try:
+            task = self.tasks[task_name]
+            on_dir_event_cb = task.on_dir_event
+        except AttributeError:
+            return defer.ensureDeferred(self.run_task(task_name))
+        else:
+            return utils.as_deferred(
+                on_dir_event_cb, host, Path(filepath.path.decode()), flags)
+
+    async def run_task_instance(self, task: Task) -> None:
+        self._current_task = task.name
+        log.info(_('== running task "{task_name}" for {site_name} =='.format(
+            task_name=task.name, site_name=self.site_name or DEFAULT_SITE_LABEL)))
+        os.chdir(self.site_path)
+        try:
+            await utils.as_deferred(task.start)
+        except Exception as e:
+            on_error = task.ON_ERROR
+            if on_error == 'stop':
+                raise e
+            elif on_error == 'continue':
+                log.warning(_('Task "{task_name}" failed for {site_name}: {reason}')
+                    .format(task_name=task.name, site_name=self.site_name, reason=e))
+            else:
+                raise exceptions.InternalError("we should never reach this point")
+        self._current_task = None
+
+    async def run_task(self, task_name: str) -> None:
+        """Run a single task
+
+        @param task_name(unicode): name of the task to run
+        """
+        task = self.tasks[task_name]
+        await self.run_task_instance(task)
+
+    async def run_tasks(self):
+        """Run all the tasks found"""
+        old_path = os.getcwd()
+        for task_name, task_value in self.tasks.items():
+            await self.run_task(task_name)
+        os.chdir(old_path)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/tasks/task.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,90 @@
+#!/usr/bin/env python3
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from twisted.python.procutils import which
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools.common import async_process
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from typing import Optional
+
+log = getLogger(__name__)
+
+
+class Task:
+    """Handle tasks of a Libervia site"""
+    # can be "stop" or "continue"
+    ON_ERROR: str = "stop"
+    LOG_OUTPUT: bool = True
+    # list of directories to check for restarting this task
+    # Task.on_dir_event will be called if it exists, otherwise
+    # the task will be run and Task.start will be called
+    WATCH_DIRS: Optional[list] = None
+    # list of task names which must be prepared/started before this one
+    AFTER: Optional[list] = None
+
+    def __init__(self, manager, task_name):
+        self.manager = manager
+        self.name = task_name
+
+    @property
+    def host(self):
+        return self.manager.host
+
+    @property
+    def resource(self):
+        return self.manager.resource
+
+    @property
+    def site_path(self):
+        return self.manager.site_path
+
+    @property
+    def build_path(self):
+        """path where generated files will be build for this site"""
+        return self.manager.build_path
+
+    def config_get(self, key, default=None, value_type=None):
+        return self.host.config_get(self.resource, key=key, default=default,
+                                   value_type=value_type)
+
+    @property
+    def site_name(self):
+        return self.resource.site_name
+
+    def find_command(self, name, *args):
+        """Find full path of a shell command
+
+        @param name(unicode): name of the command to find
+        @param *args(unicode): extra names the command may have
+        @return (unicode): full path of the command
+        @raise exceptions.NotFound: can't find this command
+        """
+        names = (name,) + args
+        for n in names:
+            try:
+                cmd_path = which(n)[0]
+            except IndexError:
+                pass
+            else:
+                return cmd_path
+        raise exceptions.NotFound(_(
+            "Can't find {name} command, did you install it?").format(name=name))
+
+    def runCommand(self, command, *args, **kwargs):
+        kwargs['verbose'] = self.LOG_OUTPUT
+        return async_process.CommandProtocol.run(command, *args, **kwargs)
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/utils.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+from libervia.backend.core.i18n import _
+from twisted.internet import reactor
+from twisted.internet import defer
+from libervia.backend.core import exceptions
+from libervia.backend.core.log import getLogger
+import urllib.request, urllib.parse, urllib.error
+
+log = getLogger(__name__)
+
+
+def quote(value, safe="@"):
+    """shortcut to quote an unicode value for URL"""
+    return urllib.parse.quote(value, safe=safe)
+
+
+class ProgressHandler(object):
+    """class to help the management of progressions"""
+
+    handlers = {}
+
+    def __init__(self, host, progress_id, profile):
+        self.host = host
+        self.progress_id = progress_id
+        self.profile = profile
+
+    @classmethod
+    def _signal(cls, name, progress_id, data, profile):
+        handlers = cls.handlers
+        if profile in handlers and progress_id in handlers[profile]:
+            handler_data = handlers[profile][progress_id]
+            timeout = handler_data["timeout"]
+            if timeout.active():
+                timeout.cancel()
+            cb = handler_data[name]
+            if cb is not None:
+                cb(data)
+            if name == "started":
+                pass
+            elif name == "finished":
+                handler_data["deferred"].callback(data)
+                handler_data["instance"].unregister_handler()
+            elif name == "error":
+                handler_data["deferred"].errback(Exception(data))
+                handler_data["instance"].unregister_handler()
+            else:
+                log.error("unexpected signal: {name}".format(name=name))
+
+    def _timeout(self):
+        log.warning(
+            _(
+                "No progress received, cancelling handler: {progress_id} [{profile}]"
+            ).format(progress_id=self.progress_id, profile=self.profile)
+        )
+
+    def unregister_handler(self):
+        """remove a previously registered handler"""
+        try:
+            del self.handlers[self.profile][self.progress_id]
+        except KeyError:
+            log.warning(
+                _("Trying to remove unknown handler: {progress_id} [{profile}]").format(
+                    progress_id=self.progress_id, profile=self.profile
+                )
+            )
+        else:
+            if not self.handlers[self.profile]:
+                self.handlers[self.profile]
+
+    def register(self, started_cb=None, finished_cb=None, error_cb=None, timeout=30):
+        """register the signals to handle progression
+
+        @param started_cb(callable, None): method to call when progress_started signal is received
+        @param finished_cb(callable, None): method to call when progress_finished signal is received
+        @param error_cb(callable, None): method to call when progress_error signal is received
+        @param timeout(int): progress time out
+            if nothing happen in this progression during this delay,
+            an exception is raised
+        @return (D(dict[unicode,unicode])): a deferred called when progression is finished
+        """
+        handler_data = self.handlers.setdefault(self.profile, {}).setdefault(
+            self.progress_id, {}
+        )
+        if handler_data:
+            raise exceptions.ConflictError(
+                "There is already one handler for this progression"
+            )
+        handler_data["instance"] = self
+        deferred = handler_data["deferred"] = defer.Deferred()
+        handler_data["started"] = started_cb
+        handler_data["finished"] = finished_cb
+        handler_data["error"] = error_cb
+        handler_data["timeout"] = reactor.callLater(timeout, self._timeout)
+        return deferred
+
+
+class SubPage(str):
+    """use to mark subpages when generating a page path"""
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/web/server/websockets.py	Fri Jun 02 16:49:28 2023 +0200
@@ -0,0 +1,224 @@
+#!/usr/bin/env python3
+
+# Libervia: a Salut à Toi frontend
+# Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org>
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+
+import json
+from typing import Optional
+
+from autobahn.twisted import websocket
+from autobahn.twisted import resource as resource
+from autobahn.websocket import types
+from libervia.backend.core import exceptions
+from libervia.backend.core.i18n import _
+from libervia.backend.core.log import getLogger
+
+from . import session_iface
+from .constants import Const as C
+
+log = getLogger(__name__)
+
+host = None
+
+
+class LiberviaPageWSProtocol(websocket.WebSocketServerProtocol):
+
+    def __init__(self):
+        super().__init__()
+        self._init_ok: bool = False
+        self.__profile: Optional[str] = None
+        self.__session: Optional[session_iface.WebSession] = None
+
+    @property
+    def init_ok(self):
+        return self._init_ok
+
+    def send(self, data_type: str, data: dict) -> None:
+        """Send data to frontend"""
+        if not self._init_ok and data_type != "error":
+            raise exceptions.InternalError(
+                "send called when not initialized, this should not happend! Please use "
+                "WebSession.send which takes care of sending correctly the data to all "
+                "sessions."
+            )
+
+        data_root = {
+            "type": data_type,
+            "data": data
+        }
+        self.sendMessage(json.dumps(data_root, ensure_ascii=False).encode())
+
+    def close(self) -> None:
+        log.debug(f"closing websocket for profile {self.__profile}")
+
+    def error(self, error_type: str, msg: str) -> None:
+        """Send an error message to frontend and log it locally"""
+        log.warning(
+            f"websocket error {error_type}: {msg}"
+        )
+        self.send("error", {
+            "type": error_type,
+            "msg": msg,
+        })
+
+    def onConnect(self, request):
+        if "libervia-page" not in request.protocols:
+            raise types.ConnectionDeny(
+                types.ConnectionDeny.NOT_IMPLEMENTED, "No supported protocol"
+            )
+        self._init_ok = False
+        cookies = {}
+        for cookie in request.headers.get("cookie", "").split(";"):
+            k, __, v = cookie.partition("=")
+            cookies[k.strip()] = v.strip()
+        session_uid = (
+            cookies.get("TWISTED_SECURE_SESSION")
+            or cookies.get("TWISTED_SESSION")
+            or ""
+        )
+        if not session_uid:
+            raise types.ConnectionDeny(
+                types.ConnectionDeny.FORBIDDEN, "No session set"
+            )
+        try:
+            session = host.site.getSession(session_uid.encode())
+        except KeyError:
+            raise types.ConnectionDeny(
+                types.ConnectionDeny.FORBIDDEN, "Invalid session"
+            )
+
+        session.touch()
+        session_data = session.getComponent(session_iface.IWebSession)
+        if session_data.ws_socket is not None:
+            log.warning(f"Session socket is already set {session_data.ws_socket=} {self=}], force closing it")
+            try:
+                session_data.ws_socket.send(
+                    "force_close", {"reason": "duplicate connection detected"}
+                )
+            except Exception as e:
+                log.warning(f"Can't force close old connection: {e}")
+        session_data.ws_socket = self
+        self.__session = session_data
+        self.__profile = session_data.profile or C.SERVICE_PROFILE
+        log.debug(f"websocket connection connected for profile {self.__profile}")
+        return "libervia-page"
+
+    def on_open(self):
+        log.debug("websocket connection opened")
+
+    def onMessage(self, payload: bytes, isBinary: bool) -> None:
+        if self.__session is None:
+            raise exceptions.InternalError("empty session, this should never happen")
+        try:
+            data_full = json.loads(payload.decode())
+            data_type = data_full["type"]
+            data = data_full["data"]
+        except ValueError as e:
+            self.error(
+                "bad_request",
+                f"Not valid JSON, ignoring data ({e}): {payload!r}"
+            )
+            return
+        except KeyError:
+            self.error(
+                "bad_request",
+                'Invalid request (missing "type" or "data")'
+            )
+            return
+
+        if data_type == "init":
+            if self._init_ok:
+                self.error(
+                    "bad_request",
+                    "double init"
+                )
+                self.sendClose(4400, "Bad Request")
+                return
+
+            try:
+                profile = data["profile"] or C.SERVICE_PROFILE
+                token = data["token"]
+            except KeyError:
+                self.error(
+                    "bad_request",
+                    "Invalid init data (missing profile or token)"
+                )
+                self.sendClose(4400, "Bad Request")
+                return
+            if ((
+                profile != self.__profile
+                or (token != self.__session.ws_token and profile != C.SERVICE_PROFILE)
+            )):
+                log.debug(
+                    f"profile got {profile}, was expecting {self.__profile}, "
+                    f"token got {token}, was expecting {self.__session.ws_token}, "
+                )
+                self.error(
+                    "Unauthorized",
+                    "Invalid profile or token"
+                )
+                self.sendClose(4401, "Unauthorized")
+                return
+            else:
+                log.debug(f"websocket connection initialized for {profile}")
+                self._init_ok = True
+                # we now send all cached data, if any
+                while True:
+                    try:
+                        session_kw = self.__session.ws_buffer.popleft()
+                    except IndexError:
+                        break
+                    else:
+                        self.send(**session_kw)
+
+        if not self._init_ok:
+            self.error(
+                "Unauthorized",
+                "session not authorized"
+            )
+            self.sendClose(4401, "Unauthorized")
+            return
+
+    def on_close(self, wasClean, code, reason):
+        log.debug(f"closing websocket (profile: {self.__profile}, reason: {reason})")
+        if self.__profile is None:
+            log.error("self.__profile should not be None")
+            self.__profile = C.SERVICE_PROFILE
+
+        if self.__session is None:
+            log.warning("closing a socket without attached session")
+        elif self.__session.ws_socket != self:
+            log.error("session socket is not linked to our instance")
+        else:
+            log.debug(f"reseting websocket session for {self.__profile}")
+            self.__session.ws_socket = None
+        sessions = session_iface.WebSession.get_profile_sessions(self.__profile)
+        log.debug(f"websocket connection for profile {self.__profile} closed")
+        self.__profile = None
+
+    @classmethod
+    def get_base_url(cls, secure):
+        return "ws{sec}://localhost:{port}".format(
+            sec="s" if secure else "",
+            port=host.options["port_https" if secure else "port"],
+        )
+
+    @classmethod
+    def get_resource(cls, secure):
+        factory = websocket.WebSocketServerFactory(cls.get_base_url(secure))
+        factory.protocol = cls
+        return resource.WebSocketResource(factory)
--- a/setup.py	Thu Jun 01 21:42:02 2023 +0200
+++ b/setup.py	Fri Jun 02 16:49:28 2023 +0200
@@ -21,8 +21,7 @@
 import os
 
 NAME = "libervia-web"
-# NOTE: directory is still "libervia" for compatibility reason, should be changed for 0.9
-DIR_NAME = "libervia"
+DIR_NAME = "libervia/web"
 
 install_requires = [
     "libervia-backend == 0.9.*",
@@ -63,9 +62,9 @@
     version=VERSION,
     description="Web frontend for Libervia",
     long_description=long_description,
-    author="Association « Salut à Toi »",
+    author="Libervia Dev Team",
     author_email="contact@goffi.org",
-    url="https://www.salut-a-toi.org",
+    url="https://www.libervia.org",
     classifiers=[
         "Programming Language :: Python :: 3 :: Only",
         "Programming Language :: Python :: 3.7",
@@ -79,7 +78,7 @@
         "Operating System :: POSIX :: Linux",
         "Topic :: Communications :: Chat",
     ],
-    packages=["libervia", "libervia.common", "libervia.server", "twisted.plugins"],
+    packages=["libervia.web", "libervia.web.common", "libervia.web.server", "twisted.plugins"],
     include_package_data=True,
     data_files=[(os.path.join("share", "doc", NAME), ["COPYING", "README", "INSTALL"])]
     + [
@@ -88,13 +87,13 @@
     ],
     entry_points={
         "console_scripts": [
-            "libervia-web = libervia.server.launcher:Launcher.run",
+            "libervia-web = libervia.web.server.launcher:Launcher.run",
             ],
         },
     zip_safe=False,
     setup_requires=["setuptools_scm"] if is_dev_version else [],
     use_scm_version=libervia_dev_version if is_dev_version else False,
     install_requires=install_requires,
-    package_data={"libervia": ["VERSION"]},
+    package_data={"libervia.web": ["VERSION"]},
     python_requires=">=3.7",
 )
--- a/twisted/plugins/libervia_server.py	Thu Jun 01 21:42:02 2023 +0200
+++ b/twisted/plugins/libervia_server.py	Fri Jun 02 16:49:28 2023 +0200
@@ -33,13 +33,13 @@
 
 import re
 import os.path
-import libervia
-import sat
+import libervia.web
+import libervia.backend
 
-from libervia.server.constants import Const as C
+from libervia.web.server.constants import Const as C
 
-from sat.core.i18n import _
-from sat.tools import config
+from libervia.backend.core.i18n import _
+from libervia.backend.tools import config
 
 from zope.interface import implementer
 
@@ -51,22 +51,22 @@
 
 RE_VER_POST = re.compile(r"\.post[0-9]+")
 
-if RE_VER_POST.sub("", libervia.__version__) != RE_VER_POST.sub("", sat.__version__):
+if RE_VER_POST.sub("", libervia.web.__version__) != RE_VER_POST.sub("", libervia.backend.__version__):
     import sys
 
     sys.stderr.write(
-        """sat module version ({sat_version}) and {current_app} version ({current_version}) mismatch
+        """libervia.backend module version ({sat_version}) and {current_app} version ({current_version}) mismatch
 
-sat module is located at {sat_path}
+libervia.backend module is located at {sat_path}
 libervia module is located at {libervia_path}
 
 Please be sure to have the same version running
 """.format(
-            sat_version=sat.__version__,
+            sat_version=libervia.backend.__version__,
             current_app=C.APP_NAME,
-            current_version=libervia.__version__,
-            sat_path=os.path.dirname(sat.__file__),
-            libervia_path=os.path.dirname(libervia.__file__),
+            current_version=libervia.web.__version__,
+            sat_path=os.path.dirname(libervia.backend.__file__),
+            libervia_path=os.path.dirname(libervia.web.__file__),
         )
     )
     sys.stderr.flush()
@@ -101,7 +101,7 @@
 DATA_DIR_DEFAULT = ''
 # prefix used for environment variables
 ENV_PREFIX = "LIBERVIA_"
-# options which are in sat.conf and on command line,
+# options which are in libervia.conf and on command line,
 # see https://twistedmatrix.com/documents/current/api/twisted.python.usage.Options.html
 OPT_PARAMETERS_BOTH = [['connection_type', 't', 'https', _("'http', 'https' or 'both' "
                         "(to launch both servers)."),
@@ -135,7 +135,7 @@
                         _('Number of tries to connect to bridge before giving up'), int],
                       ]
 
-# Options which are in sat.conf only
+# Options which are in libervia.conf only
 OPT_PARAMETERS_CFG = [
     ["empty_password_allowed_warning_dangerous_list", None, "", None],
     ["vhosts_dict", None, {}, None],
@@ -158,7 +158,7 @@
     """Method to initialise global modules"""
     # XXX: We need to configure logs before any log method is used,
     #      so here is the best place.
-    from sat.core import log_config
+    from libervia.backend.core import log_config
 
     log_config.sat_configure(C.LOG_BACKEND_TWISTED, C, backend_data=options)
 
@@ -205,7 +205,7 @@
     def handleDeprecated(self, config_parser):
         """display warning and/or change option when a deprecated option if found
 
-        param config_parser(ConfigParser): read ConfigParser instance for sat.conf
+        param config_parser(ConfigParser): read ConfigParser instance for libervia.conf
         """
         replacements = (("ssl_certificate", "tls_certificate"),)
         for old, new in replacements:
@@ -231,7 +231,7 @@
         gireactor.install()
         for opt in OPT_PARAMETERS_BOTH:
             # FIXME: that's a ugly way to get unicode in Libervia
-            #        from command line or sat.conf
+            #        from command line or libervia.conf
             #        we should move to argparse and handle options this properly
             try:
                 coerce_cb = opt[4]
@@ -242,9 +242,9 @@
                     print(f"FIXME: {opt[0]} is not unicode")
                     options[opt[0]] = options[opt[0]].decode("utf-8")
         initialise(options.parent)
-        from libervia.server import server
+        from libervia.web.server import server
 
-        return server.Libervia(options)
+        return server.LiberviaWeb(options)
 
 
 # affectation to some variable is necessary for twisted introspection to work