Mercurial > libervia-web
view libervia/server/server.py @ 1514:16228994ca3b
server: fix hot reloading of modules in dev mode
author | Goffi <goffi@goffi.org> |
---|---|
date | Mon, 22 May 2023 11:57:49 +0200 |
parents | 65e063657597 |
children | a3ca1bab6eb1 |
line wrap: on
line source
#!/usr/bin/env python3 # Libervia Web # Copyright (C) 2011-2021 Jérôme Poisson <goffi@goffi.org> # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see <http://www.gnu.org/licenses/>. from functools import partial import os.path from pathlib import Path import re import sys import time from typing import Callable, Dict, Optional import urllib.error import urllib.parse import urllib.request from twisted.application import service from twisted.internet import defer, inotify, reactor from twisted.python import failure from twisted.python import filepath from twisted.python.components import registerAdapter from twisted.web import server from twisted.web import static from twisted.web import resource as web_resource from twisted.web import util as web_util from twisted.web import vhost from twisted.words.protocols.jabber import jid import libervia from libervia.server import websockets from libervia.server import session_iface from libervia.server.constants import Const as C from libervia.server.pages import LiberviaPage from libervia.server.tasks.manager import TasksManager from libervia.server.utils import ProgressHandler from sat.core import exceptions from sat.core.i18n import _ from sat.core.log import getLogger from sat.tools import utils from sat.tools import config from sat.tools.common import regex from sat.tools.common import template from sat.tools.common import data_format from sat.tools.common import tls from sat_frontends.bridge.bridge_frontend import BridgeException from sat_frontends.bridge.dbus_bridge import BridgeExceptionNoService, bridge from sat_frontends.bridge.dbus_bridge import const_TIMEOUT as BRIDGE_TIMEOUT from .resources import LiberviaRootResource, ProtectedFile from .restricted_bridge import RestrictedBridge log = getLogger(__name__) DEFAULT_MASK = (inotify.IN_CREATE | inotify.IN_MODIFY | inotify.IN_MOVE_SELF | inotify.IN_MOVED_TO) class SysExit(Exception): def __init__(self, exit_code, message=""): self.exit_code = exit_code self.message = message def __str__(self): return f"System Exit({self.exit_code}): {self.message}" class FilesWatcher(object): """Class to check files modifications using iNotify""" _notifier = None def __init__(self, host): self.host = host @property def notifier(self): if self._notifier == None: notifier = self.__class__._notifier = inotify.INotify() notifier.startReading() return self._notifier def _check_callback(self, dir_path, callback, recursive): # Twisted doesn't add callback if a watcher was already set on a path # but in dev mode Libervia watches whole sites + internal path can be watched # by tasks, so several callbacks must be called on some paths. # This method check that the new callback is indeed present in the desired path # and add it otherwise. # FIXME: this should probably be fixed upstream if recursive: for child in dir_path.walk(): if child.isdir(): self._check_callback(child, callback, recursive=False) else: watch_id = self.notifier._isWatched(dir_path) if watch_id is None: log.warning( f"There is no watch ID for path {dir_path}, this should not happen" ) else: watch_point = self.notifier._watchpoints[watch_id] if callback not in watch_point.callbacks: watch_point.callbacks.append(callback) def watch_dir(self, dir_path, callback, mask=DEFAULT_MASK, auto_add=False, recursive=False, **kwargs): dir_path = str(dir_path) log.info(_("Watching directory {dir_path}").format(dir_path=dir_path)) wrapped_callback = lambda __, filepath, mask: callback( self.host, filepath, inotify.humanReadableMask(mask), **kwargs) callbacks = [wrapped_callback] dir_path = filepath.FilePath(dir_path) self.notifier.watch( dir_path, mask=mask, autoAdd=auto_add, recursive=recursive, callbacks=callbacks) self._check_callback(dir_path, wrapped_callback, recursive) class WebSession(server.Session): sessionTimeout = C.SESSION_TIMEOUT def __init__(self, *args, **kwargs): self.__lock = False server.Session.__init__(self, *args, **kwargs) def lock(self): """Prevent session from expiring""" self.__lock = True self._expireCall.reset(sys.maxsize) def unlock(self): """Allow session to expire again, and touch it""" self.__lock = False self.touch() def touch(self): if not self.__lock: server.Session.touch(self) class WaitingRequests(dict): def set_request(self, request, profile, register_with_ext_jid=False): """Add the given profile to the waiting list. @param request (server.Request): the connection request @param profile (str): %(doc_profile)s @param register_with_ext_jid (bool): True if we will try to register the profile with an external XMPP account credentials """ dc = reactor.callLater(BRIDGE_TIMEOUT, self.purge_request, profile) self[profile] = (request, dc, register_with_ext_jid) def purge_request(self, profile): """Remove the given profile from the waiting list. @param profile (str): %(doc_profile)s """ try: dc = self[profile][1] except KeyError: return if dc.active(): dc.cancel() del self[profile] def get_request(self, profile): """Get the waiting request for the given profile. @param profile (str): %(doc_profile)s @return: the waiting request or None """ return self[profile][0] if profile in self else None def get_register_with_ext_jid(self, profile): """Get the value of the register_with_ext_jid parameter. @param profile (str): %(doc_profile)s @return: bool or None """ return self[profile][2] if profile in self else None class Libervia(service.Service): debug = defer.Deferred.debug # True if twistd/Libervia is launched in debug mode def __init__(self, options): self.options = options websockets.host = self def _init(self): # we do init here and not in __init__ to avoid doule initialisation with twistd # this _init is called in startService self.initialised = defer.Deferred() self.waiting_profiles = WaitingRequests() # FIXME: should be removed self._main_conf = None self.files_watcher = FilesWatcher(self) if self.options["base_url_ext"]: self.base_url_ext = self.options.pop("base_url_ext") if self.base_url_ext[-1] != "/": self.base_url_ext += "/" self.base_url_ext_data = urllib.parse.urlsplit(self.base_url_ext) else: self.base_url_ext = None # we split empty string anyway so we can do things like # scheme = self.base_url_ext_data.scheme or 'https' self.base_url_ext_data = urllib.parse.urlsplit("") if not self.options["port_https_ext"]: self.options["port_https_ext"] = self.options["port_https"] self._cleanup = [] self.sessions = {} # key = session value = user self.prof_connected = set() # Profiles connected self.ns_map = {} # map of short name to namespaces ## bridge ## self._bridge_retry = self.options['bridge-retries'] self.bridge = bridge() self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) ## libervia app callbacks ## # mapping instance id to the callback to call on "started" signal self.apps_cb: Dict[str, Callable] = {} @property def roots(self): """Return available virtual host roots Root resources are only returned once, even if they are present for multiple named vhosts. Order is not relevant, except for default vhost which is always returned first. @return (list[web_resource.Resource]): all vhost root resources """ roots = list(set(self.vhost_root.hosts.values())) default = self.vhost_root.default if default is not None and default not in roots: roots.insert(0, default) return roots @property def main_conf(self): """SafeConfigParser instance opened on configuration file (sat.conf)""" if self._main_conf is None: self._main_conf = config.parse_main_conf(log_filenames=True) return self._main_conf def config_get(self, site_root_res, key, default=None, value_type=None): """Retrieve configuration associated to a site Section is automatically set to site name @param site_root_res(LiberviaRootResource): resource of the site in use @param key(unicode): key to use @param default: value to use if not found (see [config.config_get]) @param value_type(unicode, None): filter to use on value Note that filters are already automatically used when the key finish by a well known suffix ("_path", "_list", "_dict", or "_json") None to use no filter, else can be: - "path": a path is expected, will be normalized and expanded """ section = site_root_res.site_name.lower().strip() or C.CONFIG_SECTION value = config.config_get(self.main_conf, section, key, default=default) if value_type is not None: if value_type == 'path': v_filter = lambda v: os.path.abspath(os.path.expanduser(v)) else: raise ValueError("unknown value type {value_type}".format( value_type = value_type)) if isinstance(value, list): value = [v_filter(v) for v in value] elif isinstance(value, dict): value = {k:v_filter(v) for k,v in list(value.items())} elif value is not None: value = v_filter(value) return value def _namespaces_get_cb(self, ns_map): self.ns_map = {str(k): str(v) for k,v in ns_map.items()} def _namespaces_get_eb(self, failure_): log.error(_("Can't get namespaces map: {msg}").format(msg=failure_)) @template.contextfilter def _front_url_filter(self, ctx, relative_url): template_data = ctx['template_data'] return os.path.join( '/', C.TPL_RESOURCE, template_data.site or C.SITE_NAME_DEFAULT, C.TEMPLATE_TPL_DIR, template_data.theme, relative_url) def _move_first_level_to_dict(self, options, key, keys_to_keep): """Read a config option and put value at first level into u'' dict This is useful to put values for Libervia official site directly in dictionary, and to use site_name as keys when external sites are used. options will be modified in place @param options(dict): options to modify @param key(unicode): setting key to modify @param keys_to_keep(list(unicode)): keys allowed in first level """ try: conf = options[key] except KeyError: return if not isinstance(conf, dict): options[key] = {'': conf} return default_dict = conf.get('', {}) to_delete = [] for key, value in conf.items(): if key not in keys_to_keep: default_dict[key] = value to_delete.append(key) for key in to_delete: del conf[key] if default_dict: conf[''] = default_dict async def check_and_connect_service_profile(self): passphrase = self.options["passphrase"] if not passphrase: raise SysExit( C.EXIT_BAD_ARG, _("No passphrase set for service profile, please check installation " "documentation.") ) try: s_prof_connected = await self.bridge_call("is_connected", C.SERVICE_PROFILE) except BridgeException as e: if e.classname == "ProfileUnknownError": log.info("Service profile doesn't exist, creating it.") try: xmpp_domain = await self.bridge_call("config_get", "", "xmpp_domain") xmpp_domain = xmpp_domain.strip() if not xmpp_domain: raise SysExit( C.EXIT_BAD_ARG, _('"xmpp_domain" must be set to create new accounts, please ' 'check documentation') ) service_profile_jid_s = f"{C.SERVICE_PROFILE}@{xmpp_domain}" await self.bridge_call( "in_band_account_new", service_profile_jid_s, passphrase, "", xmpp_domain, 0, ) except BridgeException as e: if e.condition == "conflict": log.info( _("Service's profile JID {profile_jid} already exists") .format(profile_jid=service_profile_jid_s) ) elif e.classname == "UnknownMethod": raise SysExit( C.EXIT_BRIDGE_ERROR, _("Can't create service profile XMPP account, In-Band " "Registration plugin is not activated, you'll have to " "create the {profile!r} profile with {profile_jid!r} JID " "manually.").format( profile=C.SERVICE_PROFILE, profile_jid=service_profile_jid_s) ) elif e.condition == "service-unavailable": raise SysExit( C.EXIT_BRIDGE_ERROR, _("Can't create service profile XMPP account, In-Band " "Registration is not activated on your server, you'll have " "to create the {profile!r} profile with {profile_jid!r} JID " "manually.\nNote that you'll need to activate In-Band " "Registation on your server if you want users to be able " "to create new account from {app_name}, please check " "documentation.").format( profile=C.SERVICE_PROFILE, profile_jid=service_profile_jid_s, app_name=C.APP_NAME) ) elif e.condition == "not-acceptable": raise SysExit( C.EXIT_BRIDGE_ERROR, _("Can't create service profile XMPP account, your XMPP " "server doesn't allow us to create new accounts with " "In-Band Registration please check XMPP server " "configuration: {reason}" ).format( profile=C.SERVICE_PROFILE, profile_jid=service_profile_jid_s, reason=e.message) ) else: raise SysExit( C.EXIT_BRIDGE_ERROR, _("Can't create service profile XMPP account, you'll have " "do to it manually: {reason}").format(reason=e.message) ) try: await self.bridge_call("profile_create", C.SERVICE_PROFILE, passphrase) await self.bridge_call( "profile_start_session", passphrase, C.SERVICE_PROFILE) await self.bridge_call( "param_set", "JabberID", service_profile_jid_s, "Connection", -1, C.SERVICE_PROFILE) await self.bridge_call( "param_set", "Password", passphrase, "Connection", -1, C.SERVICE_PROFILE) except BridgeException as e: raise SysExit( C.EXIT_BRIDGE_ERROR, _("Can't create service profile XMPP account, you'll have " "do to it manually: {reason}").format(reason=e.message) ) log.info(_("Service profile has been successfully created")) s_prof_connected = False else: raise SysExit(C.EXIT_BRIDGE_ERROR, e.message) if not s_prof_connected: try: await self.bridge_call( "connect", C.SERVICE_PROFILE, passphrase, {}, ) except BridgeException as e: raise SysExit( C.EXIT_BRIDGE_ERROR, _("Connection of service profile failed: {reason}").format(reason=e) ) async def backend_ready(self): log.info(f"Libervia Web v{self.full_version}") # settings if self.options['dev-mode']: log.info(_("Developer mode activated")) self.media_dir = await self.bridge_call("config_get", "", "media_dir") self.local_dir = await self.bridge_call("config_get", "", "local_dir") self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR) self.renderer = template.Renderer(self, self._front_url_filter) sites_names = list(self.renderer.sites_paths.keys()) self._move_first_level_to_dict(self.options, "url_redirections_dict", sites_names) self._move_first_level_to_dict(self.options, "menu_json", sites_names) self._move_first_level_to_dict(self.options, "menu_extra_json", sites_names) menu = self.options["menu_json"] if not '' in menu: menu[''] = C.DEFAULT_MENU for site, value in self.options["menu_extra_json"].items(): menu[site].extend(value) # service profile if not self.options['build-only']: await self.check_and_connect_service_profile() # restricted bridge, the one used by browser code self.restricted_bridge = RestrictedBridge(self) # we create virtual hosts and import Libervia pages into them self.vhost_root = vhost.NameVirtualHost() default_site_path = Path(libervia.__file__).parent.resolve() # self.sat_root is official Libervia site root_path = default_site_path / C.TEMPLATE_STATIC_DIR self.sat_root = default_root = LiberviaRootResource( host=self, host_name='', site_name='', site_path=default_site_path, path=root_path) if self.options['dev-mode']: self.files_watcher.watch_dir( default_site_path, auto_add=True, recursive=True, callback=LiberviaPage.on_file_change, site_root=self.sat_root, site_path=default_site_path) LiberviaPage.import_pages(self, self.sat_root) tasks_manager = TasksManager(self, self.sat_root) await tasks_manager.parse_tasks() await tasks_manager.run_tasks() # FIXME: handle _set_menu in a more generic way, taking care of external sites await self.sat_root._set_menu(self.options["menu_json"]) self.vhost_root.default = default_root existing_vhosts = {b'': default_root} for host_name, site_name in self.options["vhosts_dict"].items(): if site_name == C.SITE_NAME_DEFAULT: raise ValueError( f"{C.DEFAULT_SITE_NAME} is reserved and can't be used in vhosts_dict") encoded_site_name = site_name.encode('utf-8') try: site_path = self.renderer.sites_paths[site_name] except KeyError: log.warning(_( "host {host_name} link to non existing site {site_name}, ignoring " "it").format(host_name=host_name, site_name=site_name)) continue if encoded_site_name in existing_vhosts: # we have an alias host, we re-use existing resource res = existing_vhosts[encoded_site_name] else: # for root path we first check if there is a global static dir # if not, we use default template's static dir root_path = os.path.join(site_path, C.TEMPLATE_STATIC_DIR) if not os.path.isdir(root_path): root_path = os.path.join( site_path, C.TEMPLATE_TPL_DIR, C.TEMPLATE_THEME_DEFAULT, C.TEMPLATE_STATIC_DIR) res = LiberviaRootResource( host=self, host_name=host_name, site_name=site_name, site_path=site_path, path=root_path) existing_vhosts[encoded_site_name] = res if self.options['dev-mode']: self.files_watcher.watch_dir( site_path, auto_add=True, recursive=True, callback=LiberviaPage.on_file_change, site_root=res, # FIXME: site_path should always be a Path, check code above and # in template module site_path=Path(site_path)) LiberviaPage.import_pages(self, res) # FIXME: default pages are accessible if not overriden by external website # while necessary for login or re-using existing pages # we may want to disable access to the page by direct URL # (e.g. /blog disabled except if called by external site) LiberviaPage.import_pages(self, res, root_path=default_site_path) tasks_manager = TasksManager(self, res) await tasks_manager.parse_tasks() await tasks_manager.run_tasks() await res._set_menu(self.options["menu_json"]) self.vhost_root.addHost(host_name.encode('utf-8'), res) templates_res = web_resource.Resource() self.put_child_all(C.TPL_RESOURCE.encode('utf-8'), templates_res) for site_name, site_path in self.renderer.sites_paths.items(): templates_res.putChild(site_name.encode() or C.SITE_NAME_DEFAULT.encode(), static.File(site_path)) d = self.bridge_call("namespaces_get") d.addCallback(self._namespaces_get_cb) d.addErrback(self._namespaces_get_eb) # websocket if self.options["connection_type"] in ("https", "both"): wss = websockets.LiberviaPageWSProtocol.get_resource(secure=True) self.put_child_all(b'wss', wss) if self.options["connection_type"] in ("http", "both"): ws = websockets.LiberviaPageWSProtocol.get_resource(secure=False) self.put_child_all(b'ws', ws) # following signal is needed for cache handling in Libervia pages self.bridge.register_signal( "ps_event_raw", partial(LiberviaPage.on_node_event, self), "plugin" ) self.bridge.register_signal( "message_new", partial(self.on_signal, "message_new") ) # libervia applications handling self.bridge.register_signal( "application_started", self.application_started_handler, "plugin" ) self.bridge.register_signal( "application_error", self.application_error_handler, "plugin" ) # Progress handling self.bridge.register_signal( "progress_started", partial(ProgressHandler._signal, "started") ) self.bridge.register_signal( "progress_finished", partial(ProgressHandler._signal, "finished") ) self.bridge.register_signal( "progress_error", partial(ProgressHandler._signal, "error") ) # media dirs # FIXME: get rid of dirname and "/" in C.XXX_DIR self.put_child_all(os.path.dirname(C.MEDIA_DIR).encode('utf-8'), ProtectedFile(self.media_dir)) self.cache_resource = web_resource.NoResource() self.put_child_all(C.CACHE_DIR.encode('utf-8'), self.cache_resource) self.cache_resource.putChild( b"common", ProtectedFile(str(self.cache_root_dir / Path("common")))) # redirections for root in self.roots: await root._init_redirections(self.options) # no need to keep url_redirections_dict, it will not be used anymore del self.options["url_redirections_dict"] server.Request.defaultContentType = "text/html; charset=utf-8" wrapped = web_resource.EncodingResourceWrapper( self.vhost_root, [server.GzipEncoderFactory()] ) self.site = server.Site(wrapped) self.site.sessionFactory = WebSession def _bridge_cb(self): del self._bridge_retry self.bridge.ready_get( lambda: self.initialised.callback(None), lambda failure: self.initialised.errback(Exception(failure)), ) self.initialised.addCallback(lambda __: defer.ensureDeferred(self.backend_ready())) def _bridge_eb(self, failure_): if isinstance(failure_, BridgeExceptionNoService): if self._bridge_retry: if self._bridge_retry < 0: print(_("Can't connect to bridge, will retry indefinitely. " "Next try in 1s.")) else: self._bridge_retry -= 1 print( _( "Can't connect to bridge, will retry in 1 s ({retries_left} " "trie(s) left)." ).format(retries_left=self._bridge_retry) ) time.sleep(1) self.bridge.bridge_connect(callback=self._bridge_cb, errback=self._bridge_eb) return print("Can't connect to SàT backend, are you sure it's launched ?") else: log.error("Can't connect to bridge: {}".format(failure)) sys.exit(1) @property def version(self): """Return the short version of Libervia""" return C.APP_VERSION @property def full_version(self): """Return the full version of Libervia (with extra data when in dev mode)""" version = self.version if version[-1] == "D": # we are in debug version, we add extra data try: return self._version_cache except AttributeError: self._version_cache = "{} ({})".format( version, utils.get_repository_data(libervia) ) return self._version_cache else: return version def bridge_call(self, method_name, *args, **kwargs): """Call an asynchronous bridge method and return a deferred @param method_name: name of the method as a unicode @return: a deferred which trigger the result """ d = defer.Deferred() def _callback(*args): if not args: d.callback(None) else: if len(args) != 1: Exception("Multiple return arguments not supported") d.callback(args[0]) def _errback(failure_): d.errback(failure.Failure(failure_)) kwargs["callback"] = _callback kwargs["errback"] = _errback getattr(self.bridge, method_name)(*args, **kwargs) return d def on_signal(self, signal_name, *args): profile = args[-1] if not profile: log.error(f"got signal without profile: {signal_name}, {args}") return session_iface.WebSession.send( profile, "bridge", {"signal": signal_name, "args": args} ) def application_started_handler( self, name: str, instance_id: str, extra_s: str ) -> None: callback = self.apps_cb.pop(instance_id, None) if callback is not None: defer.ensureDeferred(callback(str(name), str(instance_id))) def application_error_handler( self, name: str, instance_id: str, extra_s: str ) -> None: callback = self.apps_cb.pop(instance_id, None) if callback is not None: extra = data_format.deserialise(extra_s) log.error( f"Can't start application {name}: {extra['class']}\n{extra['msg']}" ) async def _logged(self, profile, request): """Set everything when a user just logged in @param profile @param request @return: a constant indicating the state: - C.PROFILE_LOGGED - C.PROFILE_LOGGED_EXT_JID @raise exceptions.ConflictError: session is already active """ register_with_ext_jid = self.waiting_profiles.get_register_with_ext_jid(profile) self.waiting_profiles.purge_request(profile) session = request.getSession() web_session = session_iface.IWebSession(session) if web_session.profile: log.error(_("/!\\ Session has already a profile, this should NEVER happen!")) raise failure.Failure(exceptions.ConflictError("Already active")) # XXX: we force string because python D-Bus has its own string type (dbus.String) # which may cause trouble when exposing it to scripts web_session.profile = str(profile) self.prof_connected.add(profile) cache_dir = os.path.join( self.cache_root_dir, "profiles", regex.path_escape(profile) ) # FIXME: would be better to have a global /cache URL which redirect to # profile's cache directory, without uuid self.cache_resource.putChild(web_session.uuid.encode('utf-8'), ProtectedFile(cache_dir)) log.debug( _("profile cache resource added from {uuid} to {path}").format( uuid=web_session.uuid, path=cache_dir ) ) def on_expire(): log.info("Session expired (profile={profile})".format(profile=profile)) self.cache_resource.delEntity(web_session.uuid.encode('utf-8')) log.debug( _("profile cache resource {uuid} deleted").format(uuid=web_session.uuid) ) web_session.on_expire() if web_session.ws_socket is not None: web_session.ws_socket.close() # and now we disconnect the profile self.bridge_call("disconnect", profile) session.notifyOnExpire(on_expire) # FIXME: those session infos should be returned by connect or is_connected infos = await self.bridge_call("session_infos_get", profile) web_session.jid = jid.JID(infos["jid"]) own_bare_jid_s = web_session.jid.userhost() own_id_raw = await self.bridge_call( "identity_get", own_bare_jid_s, [], True, profile) web_session.identities[own_bare_jid_s] = data_format.deserialise(own_id_raw) web_session.backend_started = int(infos["started"]) state = C.PROFILE_LOGGED_EXT_JID if register_with_ext_jid else C.PROFILE_LOGGED return state @defer.inlineCallbacks def connect(self, request, login, password): """log user in If an other user was already logged, it will be unlogged first @param request(server.Request): request linked to the session @param login(unicode): user login can be profile name can be profile@[libervia_domain.ext] can be a jid (a new profile will be created with this jid if needed) @param password(unicode): user password @return (unicode, None): C.SESSION_ACTIVE: if session was aleady active else self._logged value @raise exceptions.DataError: invalid login @raise exceptions.ProfileUnknownError: this login doesn't exist @raise exceptions.PermissionError: a login is not accepted (e.g. empty password not allowed) @raise exceptions.NotReady: a profile connection is already waiting @raise exceptions.TimeoutError: didn't received and answer from bridge @raise exceptions.InternalError: unknown error @raise ValueError(C.PROFILE_AUTH_ERROR): invalid login and/or password @raise ValueError(C.XMPP_AUTH_ERROR): invalid XMPP account password """ # XXX: all security checks must be done here, even if present in javascript if login.startswith("@"): raise failure.Failure(exceptions.DataError("No profile_key allowed")) if login.startswith("guest@@") and login.count("@") == 2: log.debug("logging a guest account") elif "@" in login: if login.count("@") != 1: raise failure.Failure( exceptions.DataError("Invalid login: {login}".format(login=login)) ) try: login_jid = jid.JID(login) except (RuntimeError, jid.InvalidFormat, AttributeError): raise failure.Failure(exceptions.DataError("No profile_key allowed")) # FIXME: should it be cached? new_account_domain = yield self.bridge_call("account_domain_new_get") if login_jid.host == new_account_domain: # redirect "user@libervia.org" to the "user" profile login = login_jid.user login_jid = None else: login_jid = None try: profile = yield self.bridge_call("profile_name_get", login) except Exception: # XXX: ProfileUnknownError wouldn't work, it's encapsulated # FIXME: find a better way to handle bridge errors if ( login_jid is not None and login_jid.user ): # try to create a new sat profile using the XMPP credentials if not self.options["allow_registration"]: log.warning( "Trying to register JID account while registration is not " "allowed") raise failure.Failure( exceptions.DataError( "JID login while registration is not allowed" ) ) profile = login # FIXME: what if there is a resource? connect_method = "credentials_xmpp_connect" register_with_ext_jid = True else: # non existing username raise failure.Failure(exceptions.ProfileUnknownError()) else: if profile != login or ( not password and profile not in self.options["empty_password_allowed_warning_dangerous_list"] ): # profiles with empty passwords are restricted to local frontends raise exceptions.PermissionError register_with_ext_jid = False connect_method = "connect" # we check if there is not already an active session web_session = session_iface.IWebSession(request.getSession()) if web_session.profile: # yes, there is if web_session.profile != profile: # it's a different profile, we need to disconnect it log.warning(_( "{new_profile} requested login, but {old_profile} was already " "connected, disconnecting {old_profile}").format( old_profile=web_session.profile, new_profile=profile)) self.purge_session(request) if self.waiting_profiles.get_request(profile): # FIXME: check if and when this can happen raise failure.Failure(exceptions.NotReady("Already waiting")) self.waiting_profiles.set_request(request, profile, register_with_ext_jid) try: connected = yield self.bridge_call(connect_method, profile, password) except Exception as failure_: fault = getattr(failure_, 'classname', None) self.waiting_profiles.purge_request(profile) if fault in ("PasswordError", "ProfileUnknownError"): log.info("Profile {profile} doesn't exist or the submitted password is " "wrong".format( profile=profile)) raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR)) elif fault == "SASLAuthError": log.info("The XMPP password of profile {profile} is wrong" .format(profile=profile)) raise failure.Failure(ValueError(C.XMPP_AUTH_ERROR)) elif fault == "NoReply": log.info(_("Did not receive a reply (the timeout expired or the " "connection is broken)")) raise exceptions.TimeOutError elif fault is None: log.info(_("Unexepected failure: {failure_}").format(failure_=failure)) raise failure_ else: log.error('Unmanaged fault class "{fault}" in errback for the ' 'connection of profile {profile}'.format( fault=fault, profile=profile)) raise failure.Failure(exceptions.InternalError(fault)) if connected: # profile is already connected in backend # do we have a corresponding session in Libervia? web_session = session_iface.IWebSession(request.getSession()) if web_session.profile: # yes, session is active if web_session.profile != profile: # existing session should have been ended above # so this line should never be reached log.error(_( "session profile [{session_profile}] differs from login " "profile [{profile}], this should not happen!") .format(session_profile=web_session.profile, profile=profile)) raise exceptions.InternalError("profile mismatch") defer.returnValue(C.SESSION_ACTIVE) log.info( _( "profile {profile} was already connected in backend".format( profile=profile ) ) ) # no, we have to create it state = yield defer.ensureDeferred(self._logged(profile, request)) defer.returnValue(state) def register_new_account(self, request, login, password, email): """Create a new account, or return error @param request(server.Request): request linked to the session @param login(unicode): new account requested login @param email(unicode): new account email @param password(unicode): new account password @return(unicode): a constant indicating the state: - C.BAD_REQUEST: something is wrong in the request (bad arguments) - C.INVALID_INPUT: one of the data is not valid - C.REGISTRATION_SUCCEED: new account has been successfully registered - C.ALREADY_EXISTS: the given profile already exists - C.INTERNAL_ERROR or any unmanaged fault string @raise PermissionError: registration is now allowed in server configuration """ if not self.options["allow_registration"]: log.warning( _("Registration received while it is not allowed, hack attempt?") ) raise failure.Failure( exceptions.PermissionError("Registration is not allowed on this server") ) if ( not re.match(C.REG_LOGIN_RE, login) or not re.match(C.REG_EMAIL_RE, email, re.IGNORECASE) or len(password) < C.PASSWORD_MIN_LENGTH ): return C.INVALID_INPUT def registered(result): return C.REGISTRATION_SUCCEED def registering_error(failure_): # FIXME: better error handling for bridge error is needed status = failure_.value.fullname.split('.')[-1] if status == "ConflictError": return C.ALREADY_EXISTS elif status == "InvalidCertificate": return C.INVALID_CERTIFICATE elif status == "InternalError": return C.INTERNAL_ERROR else: log.error( _("Unknown registering error status: {status}\n{traceback}").format( status=status, traceback=failure_.value.message ) ) return status d = self.bridge_call("libervia_account_register", email, password, login) d.addCallback(registered) d.addErrback(registering_error) return d def addCleanup(self, callback, *args, **kwargs): """Add cleaning method to call when service is stopped cleaning method will be called in reverse order of they insertion @param callback: callable to call on service stop @param *args: list of arguments of the callback @param **kwargs: list of keyword arguments of the callback""" self._cleanup.insert(0, (callback, args, kwargs)) def init_eb(self, failure): from twisted.application import app if failure.check(SysExit): if failure.value.message: log.error(failure.value.message) app._exitCode = failure.value.exit_code reactor.stop() else: log.error(_("Init error: {msg}").format(msg=failure)) app._exitCode = C.EXIT_INTERNAL_ERROR reactor.stop() return failure def _build_only_cb(self, __): log.info(_("Stopping here due to --build-only flag")) self.stop() def startService(self): """Connect the profile for Libervia and start the HTTP(S) server(s)""" self._init() if self.options['build-only']: self.initialised.addCallback(self._build_only_cb) else: self.initialised.addCallback(self._start_service) self.initialised.addErrback(self.init_eb) ## URLs ## def put_child_sat(self, path, resource): """Add a child to the sat resource""" if not isinstance(path, bytes): raise ValueError("path must be specified in bytes") self.sat_root.putChild(path, resource) def put_child_all(self, path, resource): """Add a child to all vhost root resources""" if not isinstance(path, bytes): raise ValueError("path must be specified in bytes") # we wrap before calling putChild, to avoid having useless multiple instances # of the resource # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders) wrapped_res = web_resource.EncodingResourceWrapper( resource, [server.GzipEncoderFactory()]) for root in self.roots: root.putChild(path, wrapped_res) def get_build_path(self, site_name: str, dev: bool=False) -> Path: """Generate build path for a given site name @param site_name: name of the site @param dev: return dev build dir if True, production one otherwise dev build dir is used for installing dependencies needed temporarily (e.g. to compile files), while production build path is the one served by the HTTP server, where final files are downloaded. @return: path to the build directory """ sub_dir = C.DEV_BUILD_DIR if dev else C.PRODUCTION_BUILD_DIR build_path_elts = [ config.config_get(self.main_conf, "", "local_dir"), C.CACHE_DIR, C.LIBERVIA_CACHE, sub_dir, regex.path_escape(site_name or C.SITE_NAME_DEFAULT)] build_path = Path("/".join(build_path_elts)) return build_path.expanduser().resolve() def get_ext_base_url_data(self, request): """Retrieve external base URL Data this method try to retrieve the base URL found by external user It does by checking in this order: - base_url_ext option from configuration - proxy x-forwarder-host headers - URL of the request @return (urlparse.SplitResult): SplitResult instance with only scheme and netloc filled """ ext_data = self.base_url_ext_data url_path = request.URLPath() try: forwarded = request.requestHeaders.getRawHeaders( "forwarded" )[0] except TypeError: # we try deprecated headers try: proxy_netloc = request.requestHeaders.getRawHeaders( "x-forwarded-host" )[0] except TypeError: proxy_netloc = None try: proxy_scheme = request.requestHeaders.getRawHeaders( "x-forwarded-proto" )[0] except TypeError: proxy_scheme = None else: fwd_data = { k.strip(): v.strip() for k,v in (d.split("=") for d in forwarded.split(";")) } proxy_netloc = fwd_data.get("host") proxy_scheme = fwd_data.get("proto") return urllib.parse.SplitResult( ext_data.scheme or proxy_scheme or url_path.scheme.decode(), ext_data.netloc or proxy_netloc or url_path.netloc.decode(), ext_data.path or "/", "", "", ) def get_ext_base_url( self, request: server.Request, path: str = "", query: str = "", fragment: str = "", scheme: Optional[str] = None, ) -> str: """Get external URL according to given elements external URL is the URL seen by external user @param path: same as for urlsplit.urlsplit path will be prefixed to follow found external URL if suitable @param params: same as for urlsplit.urlsplit @param query: same as for urlsplit.urlsplit @param fragment: same as for urlsplit.urlsplit @param scheme: if not None, will override scheme from base URL @return: external URL """ split_result = self.get_ext_base_url_data(request) return urllib.parse.urlunsplit( ( split_result.scheme if scheme is None else scheme, split_result.netloc, os.path.join(split_result.path, path), query, fragment, ) ) def check_redirection(self, vhost_root: LiberviaRootResource, url_path: str) -> str: """check is a part of the URL prefix is redirected then replace it @param vhost_root: root of this virtual host @param url_path: path of the url to check @return: possibly redirected URL which should link to the same location """ inv_redirections = vhost_root.inv_redirections url_parts = url_path.strip("/").split("/") for idx in range(len(url_parts), -1, -1): test_url = "/" + "/".join(url_parts[:idx]) if test_url in inv_redirections: rem_url = url_parts[idx:] return os.path.join( "/", "/".join([inv_redirections[test_url]] + rem_url) ) return url_path ## Sessions ## def purge_session(self, request): """helper method to purge a session during request handling""" session = request.session if session is not None: log.debug(_("session purge")) web_session = self.get_session_data(request, session_iface.IWebSession) socket = web_session.ws_socket if socket is not None: socket.close() session.ws_socket = None session.expire() # FIXME: not clean but it seems that it's the best way to reset # session during request handling request._secureSession = request._insecureSession = None def get_session_data(self, request, *args): """helper method to retrieve session data @param request(server.Request): request linked to the session @param *args(zope.interface.Interface): interface of the session to get @return (iterator(data)): requested session data """ session = request.getSession() if len(args) == 1: return args[0](session) else: return (iface(session) for iface in args) @defer.inlineCallbacks def get_affiliation(self, request, service, node): """retrieve pubsub node affiliation for current user use cache first, and request pubsub service if not cache is found @param request(server.Request): request linked to the session @param service(jid.JID): pubsub service @param node(unicode): pubsub node @return (unicode): affiliation """ web_session = self.get_session_data(request, session_iface.IWebSession) if web_session.profile is None: raise exceptions.InternalError("profile must be set to use this method") affiliation = web_session.get_affiliation(service, node) if affiliation is not None: defer.returnValue(affiliation) else: try: affiliations = yield self.bridge_call( "ps_affiliations_get", service.full(), node, web_session.profile ) except Exception as e: log.warning( "Can't retrieve affiliation for {service}/{node}: {reason}".format( service=service, node=node, reason=e ) ) affiliation = "" else: try: affiliation = affiliations[node] except KeyError: affiliation = "" web_session.set_affiliation(service, node, affiliation) defer.returnValue(affiliation) ## Websocket (dynamic pages) ## def get_websocket_url(self, request): base_url_split = self.get_ext_base_url_data(request) if base_url_split.scheme.endswith("s"): scheme = "wss" else: scheme = "ws" return self.get_ext_base_url(request, path=scheme, scheme=scheme) ## Various utils ## def get_http_date(self, timestamp=None): now = time.gmtime(timestamp) fmt_date = "{day_name}, %d {month_name} %Y %H:%M:%S GMT".format( day_name=C.HTTP_DAYS[now.tm_wday], month_name=C.HTTP_MONTH[now.tm_mon - 1] ) return time.strftime(fmt_date, now) ## service management ## def _start_service(self, __=None): """Actually start the HTTP(S) server(s) after the profile for Libervia is connected. @raise ImportError: OpenSSL is not available @raise IOError: the certificate file doesn't exist @raise OpenSSL.crypto.Error: the certificate file is invalid """ # now that we have service profile connected, we add resource for its cache service_path = regex.path_escape(C.SERVICE_PROFILE) cache_dir = os.path.join(self.cache_root_dir, "profiles", service_path) self.cache_resource.putChild(service_path.encode('utf-8'), ProtectedFile(cache_dir)) self.service_cache_url = "/" + os.path.join(C.CACHE_DIR, service_path) session_iface.WebSession.service_cache_url = self.service_cache_url if self.options["connection_type"] in ("https", "both"): try: tls.tls_options_check(self.options) context_factory = tls.get_tls_context_factory(self.options) except exceptions.ConfigError as e: log.warning( f"There is a problem in TLS settings in your configuration file: {e}") self.quit(2) except exceptions.DataError as e: log.warning( f"Can't set TLS: {e}") self.quit(1) reactor.listenSSL(self.options["port_https"], self.site, context_factory) if self.options["connection_type"] in ("http", "both"): if ( self.options["connection_type"] == "both" and self.options["redirect_to_https"] ): reactor.listenTCP( self.options["port"], server.Site( RedirectToHTTPS( self.options["port"], self.options["port_https_ext"] ) ), ) else: reactor.listenTCP(self.options["port"], self.site) @defer.inlineCallbacks def stopService(self): log.info(_("launching cleaning methods")) for callback, args, kwargs in self._cleanup: callback(*args, **kwargs) try: yield self.bridge_call("disconnect", C.SERVICE_PROFILE) except Exception: log.warning("Can't disconnect service profile") def run(self): reactor.run() def stop(self): reactor.stop() def quit(self, exit_code=None): """Exit app when reactor is running @param exit_code(None, int): exit code """ self.stop() sys.exit(exit_code or 0) class RedirectToHTTPS(web_resource.Resource): def __init__(self, old_port, new_port): web_resource.Resource.__init__(self) self.isLeaf = True self.old_port = old_port self.new_port = new_port def render(self, request): netloc = request.URLPath().netloc.decode().replace( f":{self.old_port}", f":{self.new_port}" ) url = f"https://{netloc}{request.uri.decode()}" return web_util.redirectTo(url.encode(), request) registerAdapter(session_iface.WebSession, server.Session, session_iface.IWebSession) registerAdapter( session_iface.SATGuestSession, server.Session, session_iface.ISATGuestSession )