diff libervia/web/server/server.py @ 1518:eb00d593801d

refactoring: rename `libervia` to `libervia.web` + update imports following backend changes
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 16:49:28 +0200
parents libervia/server/server.py@a3ca1bab6eb1
children 01b8d68edd70
line wrap: on
line diff
--- /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
+)