Mercurial > libervia-web
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 +)