diff libervia/server/server.py @ 1128:6414fd795df4

server, pages: multi-sites refactoring: Libervia is now handling external sites (i.e. other sites than Libervia official site). The external site are declared in sites_path_public_dict (in [DEFAULT] section) which is read by template engine, then they are linked to virtual host with vhosts_dict (linking host name to site name) in [libervia] section. Sites are only instanced once, so adding an alias is just a matter of mapping the alias host name in vhosts_dict with the same site name. menu_json and url_redirections_dict can now accept keys named after site name, which will be linked to the data for the site. Data for default site can still be keyed at first level. Libervia official pages are added to external site (if pages are not overriden), allowing to call pages of the framework and to have facilities like login handling. Deprecated url_redirections_profile option has been removed.
author Goffi <goffi@goffi.org>
date Fri, 14 Sep 2018 21:41:28 +0200
parents 9234f29053b0
children e6fe914c3eaf
line wrap: on
line diff
--- a/libervia/server/server.py	Sun Sep 09 21:12:22 2018 +0200
+++ b/libervia/server/server.py	Fri Sep 14 21:41:28 2018 +0200
@@ -24,6 +24,7 @@
 from twisted.web import resource as web_resource
 from twisted.web import util as web_util
 from twisted.web import http
+from twisted.web import vhost
 from twisted.python.components import registerAdapter
 from twisted.python import failure
 from twisted.words.protocols.jabber import jid
@@ -33,7 +34,6 @@
 
 from sat.core.log import getLogger
 
-log = getLogger(__name__)
 from sat_frontends.bridge.dbus_bridge import (
     Bridge,
     BridgeExceptionNoService,
@@ -44,6 +44,7 @@
 from sat.tools import utils
 from sat.tools.common import regex
 from sat.tools.common import template
+from sat.tools.common import uri as common_uri
 
 import re
 import glob
@@ -72,8 +73,11 @@
 from libervia.server.blog import MicroBlog
 from libervia.server import session_iface
 
+log = getLogger(__name__)
 
-# following value are set from twisted.plugins.libervia_server initialise (see the comment there)
+
+# following value are set from twisted.plugins.libervia_server initialise
+# (see the comment there)
 DATA_DIR_DEFAULT = OPT_PARAMETERS_BOTH = OPT_PARAMETERS_CFG = coerceDataDir = None
 
 
@@ -100,7 +104,7 @@
 
 
 class ProtectedFile(static.File):
-    """A static.File class which doens't show directory listing"""
+    """A static.File class which doesn't show directory listing"""
 
     def directoryListing(self):
         return web_resource.NoResource()
@@ -112,28 +116,46 @@
     handle redirections declared in sat.conf
     """
 
+    def __init__(self, host, host_name, site_name, site_path, *args, **kwargs):
+        ProtectedFile.__init__(self, *args, **kwargs)
+        self.host = host
+        self.host_name = host_name
+        self.site_name = site_name
+        self.site_path = site_path
+        self.named_pages = {}
+        self.uri_callbacks = {}
+        self.pages_redirects = {}
+        self.cached_urls = {}
+        self.main_menu = None
+
+    def __unicode__(self):
+        return (u"Root resource for {host_name} using {site_name} at {site_path} and "
+                u"deserving files at {path}".format(
+                host_name=self.host_name, site_name=self.site_name,
+                site_path=self.site_path, path=self.path))
+
+    def __str__(self):
+        return self.__unicode__.encode('utf-8')
+
     def _initRedirections(self, options):
+        url_redirections = options["url_redirections_dict"]
+
+        url_redirections = url_redirections.get(self.site_name, {})
+
         ## redirections
         self.redirections = {}
         self.inv_redirections = {}  # new URL to old URL map
 
-        if options["url_redirections_dict"] and not options["url_redirections_profile"]:
-            # FIXME: url_redirections_profile should not be needed. It is currently used to
-            #        redirect to an URL which associate the profile with the service, but this
-            #        is not clean, and service should be explicitly specified
-            raise ValueError(
-                u"url_redirections_profile need to be filled if you want to use url_redirections_dict"
-            )
-
-        for old, new_data in options["url_redirections_dict"].iteritems():
+        for old, new_data in url_redirections.iteritems():
             # new_data can be a dictionary or a unicode url
             if isinstance(new_data, dict):
-                # new_data dict must contain either "url", "page" or "path" key (exclusive)
+                # new_data dict must contain either "url", "page" or "path" key
+                # (exclusive)
                 # if "path" is used, a file url is constructed with it
                 if len({"path", "url", "page"}.intersection(new_data.keys())) != 1:
                     raise ValueError(
-                        u'You must have one and only one of "url", "page" or "path" key in your url_redirections_dict data'
-                    )
+                        u'You must have one and only one of "url", "page" or "path" key '
+                        u'in your url_redirections_dict data')
                 if "url" in new_data:
                     new = new_data["url"]
                 elif "page" in new_data:
@@ -142,29 +164,23 @@
                     new.setdefault("path_args", [])
                     if not isinstance(new["path_args"], list):
                         log.error(
-                            _(
-                                u'"path_args" in redirection of {old} must be a list. Ignoring the redirection'.format(
-                                    old=old
-                                )
-                            )
-                        )
+                            _(u'"path_args" in redirection of {old} must be a list. '
+                              u'Ignoring the redirection'.format(old=old)))
                         continue
                     new.setdefault("query_args", {})
                     if not isinstance(new["query_args"], dict):
                         log.error(
                             _(
-                                u'"query_args" in redirection of {old} must be a dictionary. Ignoring the redirection'.format(
-                                    old=old
-                                )
-                            )
-                        )
+                                u'"query_args" in redirection of {old} must be a '
+                                u'dictionary. Ignoring the redirection'.format(old=old)))
                         continue
                     new["path_args"] = [quote(a) for a in new["path_args"]]
-                    # we keep an inversed dict of page redirection (page/path_args => redirecting URL)
-                    # so getURL can return the redirecting URL if the same arguments are used
-                    # making the URL consistent
+                    # we keep an inversed dict of page redirection
+                    # (page/path_args => redirecting URL)
+                    # so getURL can return the redirecting URL if the same arguments
+                    # are used # making the URL consistent
                     args_hash = tuple(new["path_args"])
-                    LiberviaPage.pages_redirects.setdefault(new_data["page"], {})[
+                    self.pages_redirects.setdefault(new_data["page"], {})[
                         args_hash
                     ] = old
 
@@ -213,7 +229,7 @@
                         )
                 else:
                     if new[u"type"] == u"page":
-                        page = LiberviaPage.getPageByName(new[u"page"])
+                        page = self.getPageByName(new[u"page"])
                         url = page.getURL(*new.get(u"path_args", []))
                         self.inv_redirections[url] = old
                 continue
@@ -223,13 +239,11 @@
 
             # we handle the known URL schemes
             if new_url.scheme == "xmpp":
-                location = LiberviaPage.getPagePathFromURI(new)
+                location = self.getPagePathFromURI(new)
                 if location is None:
                     log.warning(
-                        _(
-                            u"ignoring redirection, no page found to handle this URI: {uri}"
-                        ).format(uri=new)
-                    )
+                        _(u"ignoring redirection, no page found to handle this URI: "
+                          u"{uri}").format(uri=new))
                     continue
                 request_data = self._getRequestData(location)
                 if old:
@@ -239,10 +253,9 @@
                 # direct redirection
                 if new_url.netloc:
                     raise NotImplementedError(
-                        u"netloc ({netloc}) is not implemented yet for url_redirections_dict, it is not possible to redirect to an external website".format(
-                            netloc=new_url.netloc
-                        )
-                    )
+                        u"netloc ({netloc}) is not implemented yet for "
+                        u"url_redirections_dict, it is not possible to redirect to an "
+                        u"external website".format(netloc=new_url.netloc))
                 location = urlparse.urlunsplit(
                     ("", "", new_url.path, new_url.query, new_url.fragment)
                 ).decode("utf-8")
@@ -254,17 +267,17 @@
                 # file or directory
                 if new_url.netloc:
                     raise NotImplementedError(
-                        u"netloc ({netloc}) is not implemented for url redirection to file system, it is not possible to redirect to an external host".format(
-                            netloc=new_url.netloc
-                        )
-                    )
+                        u"netloc ({netloc}) is not implemented for url redirection to "
+                        u"file system, it is not possible to redirect to an external "
+                        "host".format(
+                            netloc=new_url.netloc))
                 path = urllib.unquote(new_url.path)
                 if not os.path.isabs(path):
                     raise ValueError(
-                        u"file redirection must have an absolute path: e.g. file:/path/to/my/file"
-                    )
+                        u"file redirection must have an absolute path: e.g. "
+                        u"file:/path/to/my/file")
                 # for file redirection, we directly put child here
-                segments, dummy, last_segment = old.rpartition("/")
+                segments, __, last_segment = old.rpartition("/")
                 url_segments = segments.split("/") if segments else []
                 current = self
                 for segment in url_segments:
@@ -275,11 +288,10 @@
                     ProtectedFile if new_data.get("protected", True) else static.File
                 )
                 current.putChild(last_segment, resource_class(path))
-                log.info(
-                    u"Added redirection from /{old} to file system path {path}".format(
-                        old=old.decode("utf-8"), path=path.decode("utf-8")
-                    )
-                )
+                log.info(u"[{host_name}] Added redirection from /{old} to file system "
+                         u"path {path}".format(host_name=self.host_name,
+                                               old=old.decode("utf-8"),
+                                               path=path.decode("utf-8")))
                 continue  # we don't want to use redirection system, so we continue here
 
             else:
@@ -291,20 +303,41 @@
 
             self.redirections[old] = request_data
             if not old:
-                log.info(
-                    _(u"Root URL redirected to {uri}").format(
-                        uri=request_data[1].decode("utf-8")
-                    )
-                )
-
-        # no need to keep url_redirections*, they will not be used anymore
-        del options["url_redirections_dict"]
-        del options["url_redirections_profile"]
+                log.info(_(u"[{host_name}] Root URL redirected to {uri}")
+                    .format(host_name=self.host_name,
+                            uri=request_data[1].decode("utf-8")))
 
         # the default root URL, if not redirected
         if not "" in self.redirections:
             self.redirections[""] = self._getRequestData(C.LIBERVIA_MAIN_PAGE)
 
+    def _setMenu(self, menus):
+        menus = menus.get(self.site_name, [])
+        main_menu = []
+        for menu in menus:
+            if not menu:
+                msg = _(u"menu item can't be empty")
+                log.error(msg)
+                raise ValueError(msg)
+            elif isinstance(menu, list):
+                if len(menu) != 2:
+                    msg = _(
+                        u"menu item as list must be in the form [page_name, absolue URL]"
+                    )
+                    log.error(msg)
+                    raise ValueError(msg)
+                page_name, url = menu
+            else:
+                page_name = menu
+                try:
+                    url = self.getPageByName(page_name).url
+                except KeyError as e:
+                    log.error(_(u"Can'find a named page ({msg}), please check "
+                                u"menu_json in configuration.").format(msg=e))
+                    raise e
+            main_menu.append((page_name, url))
+        self.main_menu = main_menu
+
     def _normalizeURL(self, url, lower=True):
         """Return URL normalized for self.redirections dict
 
@@ -362,14 +395,12 @@
             pass
         else:
             try:
-                dummy, uri, dummy, dummy = request_data
+                __, uri, __, __ = request_data
             except ValueError:
                 uri = u""
-            log.error(
-                D_(
-                    u"recursive redirection, please fix this URL:\n{old} ==> {new}"
-                ).format(old=request.uri.decode("utf-8"), new=uri.decode("utf-8"))
-            )
+            log.error(D_( u"recursive redirection, please fix this URL:\n"
+                          u"{old} ==> {new}").format(
+                          old=request.uri.decode("utf-8"), new=uri.decode("utf-8")))
             return web_resource.NoResource()
 
         request._redirected = True  # here to avoid recursive redirections
@@ -377,7 +408,7 @@
         if isinstance(request_data, dict):
             if request_data["type"] == "page":
                 try:
-                    page = LiberviaPage.getPageByName(request_data["page"])
+                    page = self.getPageByName(request_data["page"])
                 except KeyError:
                     log.error(
                         _(
@@ -413,6 +444,41 @@
         # we start again to look for a child with the new url
         return self.getChildWithDefault(path_list[0], request)
 
+    def getPageByName(self, name):
+        """Retrieve page instance from its name
+
+        @param name(unicode): name of the page
+        @return (LiberviaPage): page instance
+        @raise KeyError: the page doesn't exist
+        """
+        return self.named_pages[name]
+
+    def getPagePathFromURI(self, uri):
+        """Retrieve page URL from xmpp: URI
+
+        @param uri(unicode): URI with a xmpp: scheme
+        @return (unicode,None): absolute path (starting from root "/") to page handling
+            the URI.
+            None is returned if no page has been registered for this URI
+        """
+        uri_data = common_uri.parseXMPPUri(uri)
+        try:
+            page, cb = self.uri_callbacks[uri_data["type"], uri_data["sub_type"]]
+        except KeyError:
+            url = None
+        else:
+            url = cb(page, uri_data)
+        if url is None:
+            # no handler found
+            # we try to find a more generic one
+            try:
+                page, cb = self.uri_callbacks[uri_data["type"], None]
+            except KeyError:
+                pass
+            else:
+                url = cb(page, uri_data)
+        return url
+
     def getChildWithDefault(self, name, request):
         # XXX: this method is overriden only for root url
         #      which is the only ones who need to be handled before other children
@@ -436,6 +502,15 @@
 
         return resource
 
+    def putChild(self, path, resource):
+        """Add a child to the root resource"""
+        if not isinstance(resource, web_resource.EncodingResourceWrapper):
+            # FIXME: check that no information is leaked (c.f. https://twistedmatrix.com/documents/current/web/howto/using-twistedweb.html#request-encoders)
+            resource = web_resource.EncodingResourceWrapper(
+                resource, [server.GzipEncoderFactory()])
+
+        super(LiberviaRootResource, self).putChild(path, resource)
+
     def createSimilarFile(self, path):
         # XXX: this method need to be overriden to avoid recreating a LiberviaRootResource
 
@@ -565,7 +640,8 @@
     #     @param notify (bool): True if notification is required
     #     """
     #     profile = session_iface.ISATSession(self.session).profile
-    #     return self.asyncBridgeCall("psRetractItem", service, node, item, notify, profile)
+    #     return self.asyncBridgeCall("psRetractItem", service, node, item, notify,
+    #                                 profile)
 
     # def jsonrpc_psRetractItems(self, service, node, items, notify):
     #     """Delete a whole node
@@ -576,7 +652,8 @@
     #     @param notify (bool): True if notification is required
     #     """
     #     profile = session_iface.ISATSession(self.session).profile
-    #     return self.asyncBridgeCall("psRetractItems", service, node, items, notify, profile)
+    #     return self.asyncBridgeCall("psRetractItems", service, node, items, notify,
+    #                                 profile)
 
     ## microblogging ##
 
@@ -606,7 +683,8 @@
 
         @param service_jid (unicode): pubsub service, usually publisher jid
         @param node(unicode): mblogs node, or empty string to get the defaut one
-        @param max_items (int): maximum number of item to get or C.NO_LIMIT to get everything
+        @param max_items (int): maximum number of item to get or C.NO_LIMIT to get
+            everything
         @param item_ids (list[unicode]): list of item IDs
         @param rsm (dict): TODO
         @return: a deferred couple with the list of items and metadatas.
@@ -620,8 +698,10 @@
         """Get many blog nodes at once
 
         @param publishers_type (unicode): one of "ALL", "GROUP", "JID"
-        @param publishers (tuple(unicode)): tuple of publishers (empty list for all, list of groups or list of jids)
-        @param max_items (int): maximum number of item to get or C.NO_LIMIT to get everything
+        @param publishers (tuple(unicode)): tuple of publishers (empty list for all,
+            list of groups or list of jids)
+        @param max_items (int): maximum number of item to get or C.NO_LIMIT to get
+            everything
         @param extra (dict): TODO
         @return (str): RT Deferred session id
         """
@@ -649,8 +729,10 @@
     ):
         """Helper method to get the microblogs and their comments in one shot
 
-        @param publishers_type (str): type of the list of publishers (one of "GROUP" or "JID" or "ALL")
-        @param publishers (list): list of publishers, according to publishers_type (list of groups or list of jids)
+        @param publishers_type (str): type of the list of publishers (one of "GROUP" or
+            "JID" or "ALL")
+        @param publishers (list): list of publishers, according to publishers_type
+            (list of groups or list of jids)
         @param max_items (int): optional limit on the number of retrieved items.
         @param max_comments (int): maximum number of comments to retrieve
         @param rsm_dict (dict): RSM data for initial items only
@@ -696,11 +778,13 @@
     #         if type_ == "PUBLIC":
     #             #This text if for the public microblog
     #             log.debug("sending public blog")
-    #             return self.sat_host.bridge.sendGroupBlog("PUBLIC", (), text, extra, profile)
+    #             return self.sat_host.bridge.sendGroupBlog("PUBLIC", (), text, extra,
+    #                                                       profile)
     #         else:
     #             log.debug("sending group blog")
     #             dest = dest if isinstance(dest, list) else [dest]
-    #             return self.sat_host.bridge.sendGroupBlog("GROUP", dest, text, extra, profile)
+    #             return self.sat_host.bridge.sendGroupBlog("GROUP", dest, text, extra,
+    #                                                       profile)
     #     else:
     #         raise Exception("Invalid data")
 
@@ -710,7 +794,8 @@
     #     @param comments: comments node identifier (for main item) or False
     #     """
     #     profile = session_iface.ISATSession(self.session).profile
-    #     return self.sat_host.bridge.deleteGroupBlog(pub_data, comments if comments else '', profile)
+    #     return self.sat_host.bridge.deleteGroupBlog(pub_data, comments if comments
+    #         else '', profile)
 
     # def jsonrpc_updateMblog(self, pub_data, comments, message, extra={}):
     #     """Modify a microblog node
@@ -718,13 +803,15 @@
     #     @param comments: comments node identifier (for main item) or False
     #     @param message: new message
     #     @param extra: dict which option name as key, which can be:
-    #         - allow_comments: True to accept an other level of comments, False else (default: False)
+    #         - allow_comments: True to accept an other level of comments, False else
+    #               (default: False)
     #         - rich: if present, contain rich text in currently selected syntax
     #     """
     #     profile = session_iface.ISATSession(self.session).profile
     #     if comments:
     #         extra['allow_comments'] = 'True'
-    #     return self.sat_host.bridge.updateGroupBlog(pub_data, comments if comments else '', message, extra, profile)
+    #     return self.sat_host.bridge.updateGroupBlog(pub_data, comments if comments
+    #         else '', message, extra, profile)
 
     # def jsonrpc_sendMblogComment(self, node, text, extra={}):
     #     """ Send microblog message
@@ -800,20 +887,18 @@
             and jid.JID(to_jid).userhost() != sat_jid.userhost()
         ):
             log.error(
-                u"Trying to get history from a different jid (given (browser): {}, real (backend): {}), maybe a hack attempt ?".format(
-                    from_jid, sat_jid
-                )
-            )
+                u"Trying to get history from a different jid (given (browser): {}, real "
+                u"(backend): {}), maybe a hack attempt ?".format( from_jid, sat_jid))
             return {}
         d = self.asyncBridgeCall(
-            "historyGet", from_jid, to_jid, size, between, search, profile
-        )
+            "historyGet", from_jid, to_jid, size, between, search, profile)
 
         def show(result_dbus):
             result = []
             for line in result_dbus:
-                # XXX: we have to do this stupid thing because Python D-Bus use its own types instead of standard types
-                #     and txJsonRPC doesn't accept D-Bus types, resulting in a empty query
+                # XXX: we have to do this stupid thing because Python D-Bus use its own
+                #      types instead of standard types and txJsonRPC doesn't accept
+                #      D-Bus types, resulting in a empty query
                 uuid, timestamp, from_jid, to_jid, message, subject, mess_type, extra = (
                     line
                 )
@@ -987,7 +1072,8 @@
         """Return the parameter value for profile"""
         profile = session_iface.ISATSession(self.session).profile
         if category == "Connection":
-            # we need to manage the followings params here, else SECURITY_LIMIT would block them
+            # we need to manage the followings params here, else SECURITY_LIMIT would
+            # block them
             if param == "JabberID":
                 return self.asyncBridgeCall(
                     "asyncGetParamA", param, category, attribute, profile_key=profile
@@ -1011,8 +1097,9 @@
         )
 
     def jsonrpc_launchAction(self, callback_id, data):
-        # FIXME: any action can be launched, this can be a huge security issue if callback_id can be guessed
-        #       a security system with authorised callback_id must be implemented, similar to the one for authorised params
+        # FIXME: any action can be launched, this can be a huge security issue if
+        #        callback_id can be guessed a security system with authorised
+        #        callback_id must be implemented, similar to the one for authorised params
         profile = session_iface.ISATSession(self.session).profile
         d = self.asyncBridgeCall("launchAction", callback_id, data, profile)
         return d
@@ -1068,7 +1155,8 @@
 
         @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
+        @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.purgeRequest, profile)
         self[profile] = (request, dc, register_with_ext_jid)
@@ -1117,7 +1205,8 @@
         Render method with some hacks:
            - if login is requested, try to login with form data
            - except login, every method is jsonrpc
-           - user doesn't need to be authentified for explicitely listed methods, but must be for all others
+           - user doesn't need to be authentified for explicitely listed methods,
+             but must be for all others
         """
         if request.postpath == ["login"]:
             return self.loginOrRegister(request)
@@ -1179,12 +1268,16 @@
 
         will write to request a constant indicating the state:
             - C.PROFILE_LOGGED: profile is connected
-            - C.PROFILE_LOGGED_EXT_JID: profile is connected and an external jid has been used
+            - C.PROFILE_LOGGED_EXT_JID: profile is connected and an external jid has
+                been used
             - C.SESSION_ACTIVE: session was already active
             - C.BAD_REQUEST: something is wrong in the request (bad arguments)
-            - C.PROFILE_AUTH_ERROR: either the profile (login) or the profile password is wrong
-            - C.XMPP_AUTH_ERROR: the profile is authenticated but the XMPP password is wrong
-            - C.ALREADY_WAITING: a request has already been submitted for this profil, C.PROFILE_LOGGED_EXT_JID)e
+            - C.PROFILE_AUTH_ERROR: either the profile (login) or the profile password
+                is wrong
+            - C.XMPP_AUTH_ERROR: the profile is authenticated but the XMPP password
+                is wrong
+            - C.ALREADY_WAITING: a request has already been submitted for this profile,
+                C.PROFILE_LOGGED_EXT_JID)
             - C.NOT_CONNECTED: connection has not been established
         the request will then be finished
         @param request: request of the register form
@@ -1258,8 +1351,9 @@
 
         @return (dict): metadata which can have the following keys:
             "plugged" (bool): True if a profile is already plugged
-            "warning" (unicode): a security warning message if plugged is False and if it make sense
-                this key may not be present
+            "warning" (unicode): a security warning message if plugged is False and if
+                it make sense.
+                This key may not be present.
             "allow_registration" (bool): True if registration is allowed
                 this key is only present if profile is unplugged
         @return: a couple (registered, message) with:
@@ -1349,7 +1443,8 @@
             else:
                 # the queue is empty, we delete the profile from queue
                 del self.queue[profile]
-        _session.lock()  # we don't want the session to expire as long as this connection is active
+        _session.lock()  # we don't want the session to expire as long as this
+                         # connection is active
 
         def unlock(signal, profile):
             _session.unlock()
@@ -1392,7 +1487,8 @@
     def actionNewHandler(self, action_data, action_id, security_limit, profile):
         """actionNew handler
 
-        XXX: We need need a dedicated handler has actionNew use a security_limit which must be managed
+        XXX: We need need a dedicated handler has actionNew use a security_limit
+            which must be managed
         @param action_data(dict): see bridge documentation
         @param action_id(unicode): identitifer of the action
         @param security_limit(int): %(doc_security_limit)s
@@ -1423,7 +1519,8 @@
         """Connection is done.
 
         @param profile (unicode): %(doc_profile)s
-        @param jid_s (unicode): the JID that we were assigned by the server, as the resource might differ from the JID we asked for.
+        @param jid_s (unicode): the JID that we were assigned by the server, as the
+            resource might differ from the JID we asked for.
         """
         #  FIXME: _logged should not be called from here, check this code
         #  FIXME: check if needed to connect with external jid
@@ -1442,35 +1539,25 @@
             disconnect_delta = time.time() - self._last_service_prof_disconnect
             if disconnect_delta < 15:
                 log.error(
-                    _(
-                        u"Service profile disconnected twice in a short time, please check connection"
-                    )
-                )
+                    _(u"Service profile disconnected twice in a short time, please "
+                      u"check connection"))
             else:
                 log.info(
-                    _(
-                        u"Service profile has been disconnected, but we need it! Reconnecting it..."
-                    )
-                )
+                    _(u"Service profile has been disconnected, but we need it! "
+                      u"Reconnecting it..."))
                 d = self.sat_host.bridgeCall(
                     "connect", profile, self.sat_host.options["passphrase"], {}
                 )
                 d.addErrback(
-                    lambda failure_: log.error(
-                        _(
-                            u"Can't reconnect service profile, please check connection: {reason}"
-                        ).format(reason=failure_)
-                    )
-                )
+                    lambda failure_: log.error(_(
+                        u"Can't reconnect service profile, please check connection: "
+                        u"{reason}").format(reason=failure_)))
             self._last_service_prof_disconnect = time.time()
             return
 
         if not profile in self.sat_host.prof_connected:
-            log.info(
-                _(
-                    u"'disconnected' signal received for a not connected profile ({profile})"
-                ).format(profile=profile)
-            )
+            log.info(_(u"'disconnected' signal received for a not connected profile "
+                       u"({profile})").format(profile=profile))
             return
         self.sat_host.prof_connected.remove(profile)
         if profile in self.signalDeferred:
@@ -1533,14 +1620,16 @@
         Render method with some hacks:
            - if login is requested, try to login with form data
            - except login, every method is jsonrpc
-           - user doesn't need to be authentified for getSessionMetadata, but must be for all other methods
+           - user doesn't need to be authentified for getSessionMetadata, but must be
+             for all other methods
         """
         filename = self._getFileName(request)
         filepath = os.path.join(self.upload_dir, filename)
         # FIXME: the uploaded file is fully loaded in memory at form parsing time so far
-        #       (see twisted.web.http.Request.requestReceived). A custom requestReceived should
-        #       be written in the futur. In addition, it is not yet possible to get progression informations
-        #       (see http://twistedmatrix.com/trac/ticket/288)
+        #       (see twisted.web.http.Request.requestReceived). A custom requestReceived
+        #       should be written in the futur. In addition, it is not yet possible to
+        #       get progression informations (see
+        #       http://twistedmatrix.com/trac/ticket/288)
 
         with open(filepath, "w") as f:
             f.write(request.args[self.NAME][0])
@@ -1568,7 +1657,8 @@
         return "%s%s" % (
             str(uuid.uuid4()),
             extension,
-        )  # XXX: chromium doesn't seem to play song without the .ogg extension, even with audio/ogg mime-type
+        )  # XXX: chromium doesn't seem to play song without the .ogg extension, even
+           #      with audio/ogg mime-type
 
     def _fileWritten(self, request, filepath):
         """Called once the file is actually written on disk
@@ -1638,6 +1728,21 @@
         self.bridge = Bridge()
         self.bridge.bridgeConnect(callback=self._bridgeCb, errback=self._bridgeEb)
 
+    @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
+
     def _namespacesGetCb(self, ns_map):
         self.ns_map = ns_map
 
@@ -1649,8 +1754,90 @@
         return os.path.join(u'/', C.TPL_RESOURCE, template_data.site or u'sat',
             C.TEMPLATE_TPL_DIR, template_data.theme, relative_url)
 
-    def backendReady(self, dummy):
-        self.root = root = LiberviaRootResource(self.html_dir)
+    def _moveFirstLevelToDict(self, options, key):
+        """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
+        """
+        try:
+            conf = options[key]
+        except KeyError:
+            return
+        if not isinstance(conf, dict):
+            options[key] = {u'': conf}
+            return
+        default_dict = conf.setdefault(u'', {})
+        to_delete = []
+        for key, value in conf.iteritems():
+            if not isinstance(value, dict):
+                default_dict[key] = value
+                to_delete.append(key)
+        for key in to_delete:
+            del conf[key]
+
+    def backendReady(self, __):
+        self.media_dir = self.bridge.getConfig("", "media_dir")
+        self.local_dir = self.bridge.getConfig("", "local_dir")
+        self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR)
+
+        self._moveFirstLevelToDict(self.options, "url_redirections_dict")
+        self._moveFirstLevelToDict(self.options, "menu_json")
+
+        # we create virtual hosts and import Libervia pages into them
+        self.renderer = template.Renderer(self, self._front_url_filter)
+        self.vhost_root = vhost.NameVirtualHost()
+        default_site_path = os.path.dirname(libervia.__file__)
+        # self.sat_root is official Libervia site
+        self.sat_root = default_root = LiberviaRootResource(
+            host=self, host_name=u'', site_name=u'', site_path=default_site_path,
+            path=self.html_dir)
+        LiberviaPage.importPages(self, self.sat_root)
+        # FIXME: handle _setMenu in a more generic way, taking care of external sites
+        self.sat_root._setMenu(self.options["menu_json"])
+        self.vhost_root.default = default_root
+        existing_vhosts = {u'': default_root}
+
+        for host_name, site_name in self.options["vhosts_dict"].iteritems():
+            try:
+                site_path = self.renderer.sites_paths[site_name]
+            except KeyError:
+                log.warning(_(
+                    u"host {host_name} link to non existing site {site_name}, ignoring "
+                    u"it").format(host_name=host_name, site_name=site_name))
+                continue
+            if site_name in existing_vhosts:
+                # we have an alias host, we re-use existing resource
+                res = existing_vhosts[site_name]
+            else:
+                # for root path we first check if there is a global static dir
+                # if not, we use default template's static dic
+                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)
+            self.vhost_root.addHost(host_name.encode('utf-8'), res)
+            LiberviaPage.importPages(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.importPages(self, res, root_path=default_site_path)
+            res._setMenu(self.options["menu_json"])
+
+        templates_res = web_resource.Resource()
+        self.putChildAll(C.TPL_RESOURCE, templates_res)
+        for site_name, site_path in self.renderer.sites_paths.iteritems():
+            templates_res.putChild(site_name or u'sat', ProtectedFile(site_path))
+
         _register = Register(self)
         _upload_radiocol = UploadManagerRadioCol(self)
         _upload_avatar = UploadManagerAvatar(self)
@@ -1673,7 +1860,8 @@
             self.bridge.register_signal(
                 signal_name, self.signal_handler.getGenericCb(signal_name)
             )
-        # XXX: actionNew is handled separately because the handler must manage security_limit
+        # XXX: actionNew is handled separately because the handler must manage
+        #      security_limit
         self.bridge.register_signal("actionNew", self.signal_handler.actionNewHandler)
         # plugins
         for signal_name in [
@@ -1702,34 +1890,28 @@
             self.bridge.register_signal(
                 signal_name, self.signal_handler.getGenericCb(signal_name), "plugin"
             )
-        self.media_dir = self.bridge.getConfig("", "media_dir")
-        self.local_dir = self.bridge.getConfig("", "local_dir")
-        self.cache_root_dir = os.path.join(self.local_dir, C.CACHE_DIR)
 
         # JSON APIs
-        self.putChild("json_signal_api", self.signal_handler)
-        self.putChild("json_api", MethodHandler(self))
-        self.putChild("register_api", _register)
+        self.putChildSAT("json_signal_api", self.signal_handler)
+        self.putChildSAT("json_api", MethodHandler(self))
+        self.putChildSAT("register_api", _register)
 
         # files upload
-        self.putChild("upload_radiocol", _upload_radiocol)
-        self.putChild("upload_avatar", _upload_avatar)
+        self.putChildSAT("upload_radiocol", _upload_radiocol)
+        self.putChildSAT("upload_avatar", _upload_avatar)
 
         # static pages
-        self.putChild("blog_legacy", MicroBlog(self))
-        self.putChild(C.THEMES_URL, ProtectedFile(self.themes_dir))
+        self.putChildSAT("blog_legacy", MicroBlog(self))
+        self.putChildSAT(C.THEMES_URL, ProtectedFile(self.themes_dir))
 
         # websocket
         if self.options["connection_type"] in ("https", "both"):
             wss = websockets.LiberviaPageWSProtocol.getResource(self, secure=True)
-            self.putChild("wss", wss)
+            self.putChildAll("wss", wss)
         if self.options["connection_type"] in ("http", "both"):
             ws = websockets.LiberviaPageWSProtocol.getResource(self, secure=False)
-            self.putChild("ws", ws)
+            self.putChildAll("ws", ws)
 
-        #  Libervia pages
-        LiberviaPage.importPages(self)
-        LiberviaPage.setMenu(self.options["menu_json"])
         ## following signal is needed for cache handling in Libervia pages
         self.bridge.register_signal(
             "psEventRaw", partial(LiberviaPage.onNodeEvent, self), "plugin"
@@ -1751,33 +1933,33 @@
 
         # media dirs
         # FIXME: get rid of dirname and "/" in C.XXX_DIR
-        self.putChild(os.path.dirname(C.MEDIA_DIR), ProtectedFile(self.media_dir))
+        self.putChildAll(os.path.dirname(C.MEDIA_DIR), ProtectedFile(self.media_dir))
         self.cache_resource = web_resource.NoResource()
-        self.putChild(C.CACHE_DIR, self.cache_resource)
+        self.putChildAll(C.CACHE_DIR, self.cache_resource)
 
         # special
-        self.putChild(
+        self.putChildSAT(
             "radiocol",
             ProtectedFile(_upload_radiocol.getTmpDir(), defaultType="audio/ogg"),
-        )  # FIXME: We cheat for PoC because we know we are on the same host, so we use directly upload dir
+        )  # FIXME: We cheat for PoC because we know we are on the same host, so we use
+           #        directly upload dir
         # pyjamas tests, redirected only for dev versions
         if self.version[-1] == "D":
-            self.putChild("test", web_util.Redirect("/libervia_test.html"))
+            self.putChildSAT("test", web_util.Redirect("/libervia_test.html"))
 
         # redirections
-        root._initRedirections(self.options)
+        for root in self.roots:
+            root._initRedirections(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(
-            root, [server.GzipEncoderFactory()]
+            self.vhost_root, [server.GzipEncoderFactory()]
         )
         self.site = server.Site(wrapped)
         self.site.sessionFactory = LiberviaSession
-        self.renderer = template.Renderer(self, self._front_url_filter)
-        templates_res = web_resource.Resource()
-        self.putChild(C.TPL_RESOURCE, templates_res)
-        for site_name, site_path in self.renderer.sites_paths.iteritems():
-            templates_res.putChild(site_name or u'sat', ProtectedFile(site_path))
 
     def initEb(self, failure):
         log.error(_(u"Init error: {msg}").format(msg=failure))
@@ -1806,7 +1988,7 @@
 
     @property
     def full_version(self):
-        """Return the full version of Libervia (with extra data when in development mode)"""
+        """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
@@ -1873,7 +2055,8 @@
         cache_dir = os.path.join(
             self.cache_root_dir, u"profiles", regex.pathEscape(profile)
         )
-        # FIXME: would be better to have a global /cache URL which redirect to profile's cache directory, without uuid
+        # FIXME: would be better to have a global /cache URL which redirect to
+        #        profile's cache directory, without uuid
         self.cache_resource.putChild(sat_session.uuid, ProtectedFile(cache_dir))
         log.debug(
             _(u"profile cache resource added from {uuid} to {path}").format(
@@ -1916,10 +2099,12 @@
             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
+        @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.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
@@ -1962,8 +2147,8 @@
             ):  # try to create a new sat profile using the XMPP credentials
                 if not self.options["allow_registration"]:
                     log.warning(
-                        u"Trying to register JID account while registration is not allowed"
-                    )
+                        u"Trying to register JID account while registration is not "
+                        u"allowed")
                     raise failure.Failure(
                         exceptions.DataError(
                             u"JID login while registration is not allowed"
@@ -1992,11 +2177,10 @@
             # yes, there is
             if sat_session.profile != profile:
                 # it's a different profile, we need to disconnect it
-                log.warning(
-                    _(
-                        u"{new_profile} requested login, but {old_profile} was already connected, disconnecting {old_profile}"
-                    ).format(old_profile=sat_session.profile, new_profile=profile)
-                )
+                log.warning(_(
+                    u"{new_profile} requested login, but {old_profile} was already "
+                    u"connected, disconnecting {old_profile}").format(
+                        old_profile=sat_session.profile, new_profile=profile))
                 self.purgeSession(request)
 
         if self.waiting_profiles.getRequest(profile):
@@ -2010,32 +2194,21 @@
             fault = failure_.faultString
             self.waiting_profiles.purgeRequest(profile)
             if fault in ("PasswordError", "ProfileUnknownError"):
-                log.info(
-                    u"Profile {profile} doesn't exist or the submitted password is wrong".format(
-                        profile=profile
-                    )
-                )
+                log.info(u"Profile {profile} doesn't exist or the submitted password is "
+                         u"wrong".format( profile=profile))
                 raise failure.Failure(ValueError(C.PROFILE_AUTH_ERROR))
             elif fault == "SASLAuthError":
-                log.info(
-                    u"The XMPP password of profile {profile} is wrong".format(
-                        profile=profile
-                    )
-                )
+                log.info(u"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)"
-                    )
-                )
+                log.info(_(u"Did not receive a reply (the timeout expired or the "
+                           u"connection is broken)"))
                 raise exceptions.TimeOutError
             else:
-                log.error(
-                    u'Unmanaged fault string "{fault}" in errback for the connection of profile {profile}'.format(
-                        fault=fault, profile=profile
-                    )
-                )
+                log.error(u'Unmanaged fault string "{fault}" in errback for the '
+                          u'connection of profile {profile}'.format(
+                              fault=fault, profile=profile))
                 raise failure.Failure(exceptions.InternalError(fault))
 
         if connected:
@@ -2047,11 +2220,10 @@
                 if sat_session.profile != profile:
                     # existing session should have been ended above
                     # so this line should never be reached
-                    log.error(
-                        _(
-                            u"session profile [{session_profile}] differs from login profile [{profile}], this should not happen!"
-                        ).format(session_profile=sat_session.profile, profile=profile)
-                    )
+                    log.error(_(
+                        u"session profile [{session_profile}] differs from login "
+                        u"profile [{profile}], this should not happen!")
+                            .format(session_profile=sat_session.profile, profile=profile))
                     raise exceptions.InternalError("profile mismatch")
                 defer.returnValue(C.SESSION_ACTIVE)
             log.info(
@@ -2133,17 +2305,15 @@
             log.error(_(u"Connection failed: %s") % e)
             self.stop()
 
-        def initOk(dummy):
+        def initOk(__):
             try:
                 connected = self.bridge.isConnected(C.SERVICE_PROFILE)
             except Exception as e:
                 # we don't want the traceback
                 msg = [l for l in unicode(e).split("\n") if l][-1]
                 log.error(
-                    u"Can't check service profile ({profile}), are you sure it exists ?\n{error}".format(
-                        profile=C.SERVICE_PROFILE, error=msg
-                    )
-                )
+                    u"Can't check service profile ({profile}), are you sure it exists ?"
+                    u"\n{error}".format(profile=C.SERVICE_PROFILE, error=msg))
                 self.stop()
                 return
             if not connected:
@@ -2161,13 +2331,19 @@
 
     ## URLs ##
 
-    def putChild(self, path, resource):
-        """Add a child to the root resource"""
+    def putChildSAT(self, path, resource):
+        """Add a child to the sat resource"""
+        self.sat_root.putChild(path, resource)
+
+    def putChildAll(self, path, resource):
+        """Add a child to all vhost root resources"""
+        # 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)
-        self.root.putChild(
-            path,
-            web_resource.EncodingResourceWrapper(resource, [server.GzipEncoderFactory()]),
-        )
+        wrapped_res = web_resource.EncodingResourceWrapper(
+            resource, [server.GzipEncoderFactory()])
+        for root in self.roots:
+            root.putChild(path, wrapped_res)
 
     def getExtBaseURLData(self, request):
         """Retrieve external base URL Data
@@ -2177,7 +2353,8 @@
             - 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
+        @return (urlparse.SplitResult): SplitResult instance with only scheme and
+            netloc filled
         """
         ext_data = self.base_url_ext_data
         url_path = request.URLPath()
@@ -2245,13 +2422,14 @@
             )
         )
 
-    def checkRedirection(self, url):
+    def checkRedirection(self, vhost_root, url):
         """check is a part of the URL prefix is redirected then replace it
 
+        @param vhost_root(web_resource.Resource): root of this virtual host
         @param url(unicode): url to check
         @return (unicode): possibly redirected URL which should link to the same location
         """
-        inv_redirections = self.root.inv_redirections
+        inv_redirections = vhost_root.inv_redirections
         url_parts = url.strip(u"/").split(u"/")
         for idx in xrange(len(url), 0, -1):
             test_url = u"/" + u"/".join(url_parts[:idx])
@@ -2434,26 +2612,23 @@
                 self.quit(2)
             except OpenSSL.crypto.Error:
                 log.error(
-                    u"Error while parsing file {path} for option {option}, are you sure it is a valid .pem file?".format(
-                        path=path, option=option
-                    )
-                )
+                    u"Error while parsing file {path} for option {option}, are you sure "
+                    u"it is a valid .pem file?".format( path=path, option=option))
                 if (
                     option == "tls_private_key"
                     and self.options["tls_certificate"] == path
                 ):
                     log.error(
-                        u"You are using the same file for private key and public certificate, make sure that both a in {path} or use --tls_private_key option".format(
-                            path=path
-                        )
-                    )
+                        u"You are using the same file for private key and public "
+                        u"certificate, make sure that both a in {path} or use "
+                        u"--tls_private_key option".format(path=path))
                 self.quit(2)
 
         return ssl.CertificateOptions(**cert_options)
 
     ## service management ##
 
-    def _startService(self, dummy=None):
+    def _startService(self, __=None):
         """Actually start the HTTP(S) server(s) after the profile for Libervia is connected.
 
         @raise ImportError: OpenSSL is not available