diff libervia/backend/plugins/plugin_xep_0329.py @ 4071:4b842c1fb686

refactoring: renamed `sat` package to `libervia.backend`
author Goffi <goffi@goffi.org>
date Fri, 02 Jun 2023 11:49:51 +0200
parents sat/plugins/plugin_xep_0329.py@524856bd7b19
children 0d7bb4df2343
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/libervia/backend/plugins/plugin_xep_0329.py	Fri Jun 02 11:49:51 2023 +0200
@@ -0,0 +1,1275 @@
+#!/usr/bin/env python3
+
+# SAT plugin for File Information Sharing (XEP-0329)
+# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import mimetypes
+import json
+import os
+import traceback
+from pathlib import Path
+from typing import Optional, Dict
+from zope.interface import implementer
+from twisted.words.protocols.jabber import xmlstream
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber import error as jabber_error
+from twisted.internet import defer
+from wokkel import disco, iwokkel, data_form
+from libervia.backend.core.i18n import _
+from libervia.backend.core.xmpp import SatXMPPEntity
+from libervia.backend.core import exceptions
+from libervia.backend.core.constants import Const as C
+from libervia.backend.core.log import getLogger
+from libervia.backend.tools import stream
+from libervia.backend.tools import utils
+from libervia.backend.tools.common import regex
+
+
+log = getLogger(__name__)
+
+PLUGIN_INFO = {
+    C.PI_NAME: "File Information Sharing",
+    C.PI_IMPORT_NAME: "XEP-0329",
+    C.PI_TYPE: "XEP",
+    C.PI_MODES: C.PLUG_MODE_BOTH,
+    C.PI_PROTOCOLS: ["XEP-0329"],
+    C.PI_DEPENDENCIES: ["XEP-0231", "XEP-0234", "XEP-0300", "XEP-0106"],
+    C.PI_MAIN: "XEP_0329",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _("""Implementation of File Information Sharing"""),
+}
+
+NS_FIS = "urn:xmpp:fis:0"
+NS_FIS_AFFILIATION = "org.salut-a-toi.fis-affiliation"
+NS_FIS_CONFIGURATION = "org.salut-a-toi.fis-configuration"
+NS_FIS_CREATE = "org.salut-a-toi.fis-create"
+
+IQ_FIS_REQUEST = f'{C.IQ_GET}/query[@xmlns="{NS_FIS}"]'
+# not in the standard, but needed, and it's handy to keep it here
+IQ_FIS_AFFILIATION_GET = f'{C.IQ_GET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
+IQ_FIS_AFFILIATION_SET = f'{C.IQ_SET}/affiliations[@xmlns="{NS_FIS_AFFILIATION}"]'
+IQ_FIS_CONFIGURATION_GET = f'{C.IQ_GET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
+IQ_FIS_CONFIGURATION_SET = f'{C.IQ_SET}/configuration[@xmlns="{NS_FIS_CONFIGURATION}"]'
+IQ_FIS_CREATE_DIR = f'{C.IQ_SET}/dir[@xmlns="{NS_FIS_CREATE}"]'
+SINGLE_FILES_DIR = "files"
+TYPE_VIRTUAL = "virtual"
+TYPE_PATH = "path"
+SHARE_TYPES = (TYPE_PATH, TYPE_VIRTUAL)
+KEY_TYPE = "type"
+
+
+class RootPathException(Exception):
+    """Root path is requested"""
+
+
+class ShareNode(object):
+    """Node containing directory or files to share, virtual or real"""
+
+    host = None
+
+    def __init__(self, name, parent, type_, access, path=None):
+        assert type_ in SHARE_TYPES
+        if name is not None:
+            if name == ".." or "/" in name or "\\" in name:
+                log.warning(
+                    _("path change chars found in name [{name}], hack attempt?").format(
+                        name=name
+                    )
+                )
+                if name == "..":
+                    name = "--"
+                else:
+                    name = regex.path_escape(name)
+        self.name = name
+        self.children = {}
+        self.type = type_
+        self.access = {} if access is None else access
+        assert isinstance(self.access, dict)
+        self.parent = None
+        if parent is not None:
+            assert name
+            parent.addChild(self)
+        else:
+            assert name is None
+        if path is not None:
+            if type_ != TYPE_PATH:
+                raise exceptions.InternalError(_("path can only be set on path nodes"))
+            self._path = path
+
+    @property
+    def path(self):
+        return self._path
+
+    def __getitem__(self, key):
+        return self.children[key]
+
+    def __contains__(self, item):
+        return self.children.__contains__(item)
+
+    def __iter__(self):
+        return self.children.__iter__()
+
+    def items(self):
+        return self.children.items()
+
+    def values(self):
+        return self.children.values()
+
+    def get_or_create(self, name, type_=TYPE_VIRTUAL, access=None):
+        """Get a node or create a virtual node and return it"""
+        if access is None:
+            access = {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}}
+        try:
+            return self.children[name]
+        except KeyError:
+            node = ShareNode(name, self, type_=type_, access=access)
+            return node
+
+    def addChild(self, node):
+        if node.parent is not None:
+            raise exceptions.ConflictError(_("a node can't have several parents"))
+        node.parent = self
+        self.children[node.name] = node
+
+    def remove_from_parent(self):
+        try:
+            del self.parent.children[self.name]
+        except TypeError:
+            raise exceptions.InternalError(
+                "trying to remove a node from inexisting parent"
+            )
+        except KeyError:
+            raise exceptions.InternalError("node not found in parent's children")
+        self.parent = None
+
+    def _check_node_permission(self, client, node, perms, peer_jid):
+        """Check access to this node for peer_jid
+
+        @param node(SharedNode): node to check access
+        @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
+        @param peer_jid(jid.JID): entity which try to access the node
+        @return (bool): True if entity can access
+        """
+        file_data = {"access": self.access, "owner": client.jid.userhostJID()}
+        try:
+            self.host.memory.check_file_permission(file_data, peer_jid, perms)
+        except exceptions.PermissionError:
+            return False
+        else:
+            return True
+
+    def check_permissions(
+        self, client, peer_jid, perms=(C.ACCESS_PERM_READ,), check_parents=True
+    ):
+        """Check that peer_jid can access this node and all its parents
+
+        @param peer_jid(jid.JID): entrity trying to access the node
+        @param perms(unicode): permissions to check, iterable of C.ACCESS_PERM_*
+        @param check_parents(bool): if True, access of all parents of this node will be
+            checked too
+        @return (bool): True if entity can access this node
+        """
+        peer_jid = peer_jid.userhostJID()
+        if peer_jid == client.jid.userhostJID():
+            return True
+
+        parent = self
+        while parent != None:
+            if not self._check_node_permission(client, parent, perms, peer_jid):
+                return False
+            parent = parent.parent
+
+        return True
+
+    @staticmethod
+    def find(client, path, peer_jid, perms=(C.ACCESS_PERM_READ,)):
+        """find node corresponding to a path
+
+        @param path(unicode): path to the requested file or directory
+        @param peer_jid(jid.JID): entity trying to find the node
+            used to check permission
+        @return (dict, unicode): shared data, remaining path
+        @raise exceptions.PermissionError: user can't access this file
+        @raise exceptions.DataError: path is invalid
+        @raise NotFound: path lead to a non existing file/directory
+        """
+        path_elts = [_f for _f in path.split("/") if _f]
+
+        if ".." in path_elts:
+            log.warning(_(
+                'parent dir ("..") found in path, hack attempt? path is {path} '
+                '[{profile}]').format(path=path, profile=client.profile))
+            raise exceptions.PermissionError("illegal path elements")
+
+        node = client._XEP_0329_root_node
+
+        while path_elts:
+            if node.type == TYPE_VIRTUAL:
+                try:
+                    node = node[path_elts.pop(0)]
+                except KeyError:
+                    raise exceptions.NotFound
+            elif node.type == TYPE_PATH:
+                break
+
+        if not node.check_permissions(client, peer_jid, perms=perms):
+            raise exceptions.PermissionError("permission denied")
+
+        return node, "/".join(path_elts)
+
+    def find_by_local_path(self, path):
+        """retrieve nodes linking to local path
+
+        @return (list[ShareNode]): found nodes associated to path
+        @raise exceptions.NotFound: no node has been found with this path
+        """
+        shared_paths = self.get_shared_paths()
+        try:
+            return shared_paths[path]
+        except KeyError:
+            raise exceptions.NotFound
+
+    def _get_shared_paths(self, node, paths):
+        if node.type == TYPE_VIRTUAL:
+            for node in node.values():
+                self._get_shared_paths(node, paths)
+        elif node.type == TYPE_PATH:
+            paths.setdefault(node.path, []).append(node)
+        else:
+            raise exceptions.InternalError(
+                "unknown node type: {type}".format(type=node.type)
+            )
+
+    def get_shared_paths(self):
+        """retrieve nodes by shared path
+
+        this method will retrieve recursively shared path in children of this node
+        @return (dict): map from shared path to list of nodes
+        """
+        if self.type == TYPE_PATH:
+            raise exceptions.InternalError(
+                "get_shared_paths must be used on a virtual node"
+            )
+        paths = {}
+        self._get_shared_paths(self, paths)
+        return paths
+
+
+class XEP_0329(object):
+    def __init__(self, host):
+        log.info(_("File Information Sharing initialization"))
+        self.host = host
+        ShareNode.host = host
+        self._b = host.plugins["XEP-0231"]
+        self._h = host.plugins["XEP-0300"]
+        self._jf = host.plugins["XEP-0234"]
+        host.bridge.add_method(
+            "fis_list",
+            ".plugin",
+            in_sign="ssa{ss}s",
+            out_sign="aa{ss}",
+            method=self._list_files,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_local_shares_get",
+            ".plugin",
+            in_sign="s",
+            out_sign="as",
+            method=self._local_shares_get,
+        )
+        host.bridge.add_method(
+            "fis_share_path",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="s",
+            method=self._share_path,
+        )
+        host.bridge.add_method(
+            "fis_unshare_path",
+            ".plugin",
+            in_sign="ss",
+            out_sign="",
+            method=self._unshare_path,
+        )
+        host.bridge.add_method(
+            "fis_affiliations_get",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="a{ss}",
+            method=self._affiliations_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_affiliations_set",
+            ".plugin",
+            in_sign="sssa{ss}s",
+            out_sign="",
+            method=self._affiliations_set,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_configuration_get",
+            ".plugin",
+            in_sign="ssss",
+            out_sign="a{ss}",
+            method=self._configuration_get,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_configuration_set",
+            ".plugin",
+            in_sign="sssa{ss}s",
+            out_sign="",
+            method=self._configuration_set,
+            async_=True,
+        )
+        host.bridge.add_method(
+            "fis_create_dir",
+            ".plugin",
+            in_sign="sssa{ss}s",
+            out_sign="",
+            method=self._create_dir,
+            async_=True,
+        )
+        host.bridge.add_signal("fis_shared_path_new", ".plugin", signature="sss")
+        host.bridge.add_signal("fis_shared_path_removed", ".plugin", signature="ss")
+        host.trigger.add("XEP-0234_fileSendingRequest", self._file_sending_request_trigger)
+        host.register_namespace("fis", NS_FIS)
+
+    def get_handler(self, client):
+        return XEP_0329_handler(self)
+
+    def profile_connected(self, client):
+        if client.is_component:
+            client._file_sharing_allowed_hosts = self.host.memory.config_get(
+                'component file_sharing',
+                'http_upload_allowed_hosts_list') or [client.host]
+        else:
+            client._XEP_0329_root_node = ShareNode(
+                None,
+                None,
+                TYPE_VIRTUAL,
+                {C.ACCESS_PERM_READ: {KEY_TYPE: C.ACCESS_TYPE_PUBLIC}},
+            )
+            client._XEP_0329_names_data = {}  #  name to share map
+
+    def _file_sending_request_trigger(
+        self, client, session, content_data, content_name, file_data, file_elt
+    ):
+        """This trigger check that a requested file is available, and fill suitable data
+
+        Path and name are used to retrieve the file. If path is missing, we try our luck
+        with known names
+        """
+        if client.is_component:
+            return True, None
+
+        try:
+            name = file_data["name"]
+        except KeyError:
+            return True, None
+        assert "/" not in name
+
+        path = file_data.get("path")
+        if path is not None:
+            # we have a path, we can follow it to find node
+            try:
+                node, rem_path = ShareNode.find(client, path, session["peer_jid"])
+            except (exceptions.PermissionError, exceptions.NotFound):
+                #  no file, or file not allowed, we continue normal workflow
+                return True, None
+            except exceptions.DataError:
+                log.warning(_("invalid path: {path}").format(path=path))
+                return True, None
+
+            if node.type == TYPE_VIRTUAL:
+                # we have a virtual node, so name must link to a path node
+                try:
+                    path = node[name].path
+                except KeyError:
+                    return True, None
+            elif node.type == TYPE_PATH:
+                # we have a path node, so we can retrieve the full path now
+                path = os.path.join(node.path, rem_path, name)
+            else:
+                raise exceptions.InternalError(
+                    "unknown type: {type}".format(type=node.type)
+                )
+            if not os.path.exists(path):
+                return True, None
+            size = os.path.getsize(path)
+        else:
+            # we don't have the path, we try to find the file by its name
+            try:
+                name_data = client._XEP_0329_names_data[name]
+            except KeyError:
+                return True, None
+
+            for path, shared_file in name_data.items():
+                if True:  #  FIXME: filters are here
+                    break
+            else:
+                return True, None
+            parent_node = shared_file["parent"]
+            if not parent_node.check_permissions(client, session["peer_jid"]):
+                log.warning(
+                    _(
+                        "{peer_jid} requested a file (s)he can't access [{profile}]"
+                    ).format(peer_jid=session["peer_jid"], profile=client.profile)
+                )
+                return True, None
+            size = shared_file["size"]
+
+        file_data["size"] = size
+        file_elt.addElement("size", content=str(size))
+        hash_algo = file_data["hash_algo"] = self._h.get_default_algo()
+        hasher = file_data["hash_hasher"] = self._h.get_hasher(hash_algo)
+        file_elt.addChild(self._h.build_hash_used_elt(hash_algo))
+        content_data["stream_object"] = stream.FileStreamObject(
+            self.host,
+            client,
+            path,
+            uid=self._jf.get_progress_id(session, content_name),
+            size=size,
+            data_cb=lambda data: hasher.update(data),
+        )
+        return False, defer.succeed(True)
+
+    # common methods
+
+    def _request_handler(self, client, iq_elt, root_nodes_cb, files_from_node_cb):
+        iq_elt.handled = True
+        node = iq_elt.query.getAttribute("node")
+        if not node:
+            d = utils.as_deferred(root_nodes_cb, client, iq_elt)
+        else:
+            d = utils.as_deferred(files_from_node_cb, client, iq_elt, node)
+        d.addErrback(
+            lambda failure_: log.error(
+                _("error while retrieving files: {msg}").format(msg=failure_)
+            )
+        )
+
+    def _iq_error(self, client, iq_elt, condition="item-not-found"):
+        error_elt = jabber_error.StanzaError(condition).toResponse(iq_elt)
+        client.send(error_elt)
+
+    #  client
+
+    def _add_path_data(self, client, query_elt, path, parent_node):
+        """Fill query_elt with files/directories found in path"""
+        name = os.path.basename(path)
+        if os.path.isfile(path):
+            size = os.path.getsize(path)
+            mime_type = mimetypes.guess_type(path, strict=False)[0]
+            file_elt = self._jf.build_file_element(
+                client=client, name=name, size=size, mime_type=mime_type,
+                modified=os.path.getmtime(path)
+            )
+
+            query_elt.addChild(file_elt)
+            # we don't specify hash as it would be too resource intensive to calculate
+            # it for all files.
+            # we add file to name_data, so users can request it later
+            name_data = client._XEP_0329_names_data.setdefault(name, {})
+            if path not in name_data:
+                name_data[path] = {
+                    "size": size,
+                    "mime_type": mime_type,
+                    "parent": parent_node,
+                }
+        else:
+            # we have a directory
+            directory_elt = query_elt.addElement("directory")
+            directory_elt["name"] = name
+
+    def _path_node_handler(self, client, iq_elt, query_elt, node, path):
+        """Fill query_elt for path nodes, i.e. physical directories"""
+        path = os.path.join(node.path, path)
+
+        if not os.path.exists(path):
+            # path may have been moved since it has been shared
+            return self._iq_error(client, iq_elt)
+        elif os.path.isfile(path):
+            self._add_path_data(client, query_elt, path, node)
+        else:
+            for name in sorted(os.listdir(path.encode("utf-8")), key=lambda n: n.lower()):
+                try:
+                    name = name.decode("utf-8", "strict")
+                except UnicodeDecodeError as e:
+                    log.warning(
+                        _("ignoring invalid unicode name ({name}): {msg}").format(
+                            name=name.decode("utf-8", "replace"), msg=e
+                        )
+                    )
+                    continue
+                full_path = os.path.join(path, name)
+                self._add_path_data(client, query_elt, full_path, node)
+
+    def _virtual_node_handler(self, client, peer_jid, iq_elt, query_elt, node):
+        """Fill query_elt for virtual nodes"""
+        for name, child_node in node.items():
+            if not child_node.check_permissions(client, peer_jid, check_parents=False):
+                continue
+            node_type = child_node.type
+            if node_type == TYPE_VIRTUAL:
+                directory_elt = query_elt.addElement("directory")
+                directory_elt["name"] = name
+            elif node_type == TYPE_PATH:
+                self._add_path_data(client, query_elt, child_node.path, child_node)
+            else:
+                raise exceptions.InternalError(
+                    _("unexpected type: {type}").format(type=node_type)
+                )
+
+    def _get_root_nodes_cb(self, client, iq_elt):
+        peer_jid = jid.JID(iq_elt["from"])
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        for name, node in client._XEP_0329_root_node.items():
+            if not node.check_permissions(client, peer_jid, check_parents=False):
+                continue
+            directory_elt = query_elt.addElement("directory")
+            directory_elt["name"] = name
+        client.send(iq_result_elt)
+
+    def _get_files_from_node_cb(self, client, iq_elt, node_path):
+        """Main method to retrieve files/directories from a node_path"""
+        peer_jid = jid.JID(iq_elt["from"])
+        try:
+            node, path = ShareNode.find(client, node_path, peer_jid)
+        except (exceptions.PermissionError, exceptions.NotFound):
+            return self._iq_error(client, iq_elt)
+        except exceptions.DataError:
+            return self._iq_error(client, iq_elt, condition="not-acceptable")
+
+        node_type = node.type
+        peer_jid = jid.JID(iq_elt["from"])
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        query_elt["node"] = node_path
+
+        # we now fill query_elt according to node_type
+        if node_type == TYPE_PATH:
+            #  it's a physical path
+            self._path_node_handler(client, iq_elt, query_elt, node, path)
+        elif node_type == TYPE_VIRTUAL:
+            assert not path
+            self._virtual_node_handler(client, peer_jid, iq_elt, query_elt, node)
+        else:
+            raise exceptions.InternalError(
+                _("unknown node type: {type}").format(type=node_type)
+            )
+
+        client.send(iq_result_elt)
+
+    def on_request(self, iq_elt, client):
+        return self._request_handler(
+            client, iq_elt, self._get_root_nodes_cb, self._get_files_from_node_cb
+        )
+
+    # Component
+
+    def _comp_parse_jids(self, client, iq_elt):
+        """Retrieve peer_jid and owner to use from IQ stanza
+
+        @param iq_elt(domish.Element): IQ stanza of the FIS request
+        @return (tuple[jid.JID, jid.JID]): peer_jid and owner
+        """
+
+    async def _comp_get_root_nodes_cb(self, client, iq_elt):
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        files_data = await self.host.memory.get_files(
+            client,
+            peer_jid=peer_jid,
+            parent="",
+            type_=C.FILE_TYPE_DIRECTORY,
+            owner=owner,
+        )
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        for file_data in files_data:
+            name = file_data["name"]
+            directory_elt = query_elt.addElement("directory")
+            directory_elt["name"] = name
+        client.send(iq_result_elt)
+
+    async def _comp_get_files_from_node_cb(self, client, iq_elt, node_path):
+        """Retrieve files from local files repository according to permissions
+
+        result stanza is then built and sent to requestor
+        @trigger XEP-0329_compGetFilesFromNode(client, iq_elt, owner, node_path,
+                                               files_data):
+            can be used to add data/elements
+        """
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        try:
+            files_data = await self.host.memory.get_files(
+                client, peer_jid=peer_jid, path=node_path, owner=owner
+            )
+        except exceptions.NotFound:
+            self._iq_error(client, iq_elt)
+            return
+        except exceptions.PermissionError:
+            self._iq_error(client, iq_elt, condition='not-allowed')
+            return
+        except Exception as e:
+            tb = traceback.format_tb(e.__traceback__)
+            log.error(f"internal server error: {e}\n{''.join(tb)}")
+            self._iq_error(client, iq_elt, condition='internal-server-error')
+            return
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        query_elt = iq_result_elt.addElement((NS_FIS, "query"))
+        query_elt["node"] = node_path
+        if not self.host.trigger.point(
+            "XEP-0329_compGetFilesFromNode",
+            client,
+            iq_elt,
+            iq_result_elt,
+            owner,
+            node_path,
+            files_data
+        ):
+            return
+        for file_data in files_data:
+            if file_data['type'] == C.FILE_TYPE_DIRECTORY:
+                directory_elt = query_elt.addElement("directory")
+                directory_elt['name'] = file_data['name']
+                self.host.trigger.point(
+                    "XEP-0329_compGetFilesFromNode_build_directory",
+                    client,
+                    file_data,
+                    directory_elt,
+                    owner,
+                    node_path,
+                )
+            else:
+                file_elt = self._jf.build_file_element_from_dict(
+                    client,
+                    file_data,
+                    modified=file_data.get("modified", file_data["created"])
+                )
+                query_elt.addChild(file_elt)
+        client.send(iq_result_elt)
+
+    def on_component_request(self, iq_elt, client):
+        return self._request_handler(
+            client, iq_elt, self._comp_get_root_nodes_cb, self._comp_get_files_from_node_cb
+        )
+
+    async def _parse_result(self, client, peer_jid, iq_elt):
+        query_elt = next(iq_elt.elements(NS_FIS, "query"))
+        files = []
+
+        for elt in query_elt.elements():
+            if elt.name == "file":
+                # we have a file
+                try:
+                    file_data = await self._jf.parse_file_element(client, elt)
+                except exceptions.DataError:
+                    continue
+                file_data["type"] = C.FILE_TYPE_FILE
+                try:
+                    thumbs = file_data['extra'][C.KEY_THUMBNAILS]
+                except KeyError:
+                    log.debug(f"No thumbnail found for {file_data}")
+                else:
+                    for thumb in thumbs:
+                        if 'url' not in thumb and "id" in thumb:
+                            try:
+                                file_path = await self._b.get_file(client, peer_jid, thumb['id'])
+                            except Exception as e:
+                                log.warning(f"Can't get thumbnail {thumb['id']!r} for {file_data}: {e}")
+                            else:
+                                thumb['filename'] = file_path.name
+
+            elif elt.name == "directory" and elt.uri == NS_FIS:
+                # we have a directory
+
+                file_data = {"name": elt["name"], "type": C.FILE_TYPE_DIRECTORY}
+                self.host.trigger.point(
+                    "XEP-0329_parseResult_directory",
+                    client,
+                    elt,
+                    file_data,
+                )
+            else:
+                log.warning(
+                    _("unexpected element, ignoring: {elt}")
+                    .format(elt=elt.toXml())
+                )
+                continue
+            files.append(file_data)
+        return files
+
+    # affiliations #
+
+    async def _parse_element(self, client, iq_elt, element, namespace):
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        elt = next(iq_elt.elements(namespace, element))
+        path = Path("/", elt['path'])
+        if len(path.parts) < 2:
+            raise RootPathException
+        namespace = elt.getAttribute('namespace')
+        files_data = await self.host.memory.get_files(
+            client,
+            peer_jid=peer_jid,
+            path=str(path.parent),
+            name=path.name,
+            namespace=namespace,
+            owner=owner,
+        )
+        if len(files_data) != 1:
+            client.sendError(iq_elt, 'item-not-found')
+            raise exceptions.CancelError
+        file_data = files_data[0]
+        return peer_jid, elt, path, namespace, file_data
+
+    def _affiliations_get(self, service_jid_s, namespace, path, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        d = defer.ensureDeferred(self.affiliationsGet(
+            client, service, namespace or None, path))
+        d.addCallback(
+            lambda affiliations: {
+                str(entity): affiliation for entity, affiliation in affiliations.items()
+            }
+        )
+        return d
+
+    async def affiliationsGet(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str
+    ) -> Dict[jid.JID, str]:
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("get")
+        iq_elt['to'] = service.full()
+        affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
+        if namespace:
+            affiliations_elt["namespace"] = namespace
+        affiliations_elt["path"] = path
+        iq_result_elt = await iq_elt.send()
+        try:
+            affiliations_elt = next(iq_result_elt.elements(NS_FIS_AFFILIATION, "affiliations"))
+        except StopIteration:
+            raise exceptions.DataError(f"Invalid result to affiliations request: {iq_result_elt.toXml()}")
+
+        affiliations = {}
+        for affiliation_elt in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation'):
+            try:
+                affiliations[jid.JID(affiliation_elt['jid'])] = affiliation_elt['affiliation']
+            except (KeyError, RuntimeError):
+                raise exceptions.DataError(
+                    f"invalid affiliation element: {affiliation_elt.toXml()}")
+
+        return affiliations
+
+    def _affiliations_set(self, service_jid_s, namespace, path, affiliations, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        affiliations = {jid.JID(e): a for e, a in affiliations.items()}
+        return defer.ensureDeferred(self.affiliationsSet(
+            client, service, namespace or None, path, affiliations))
+
+    async def affiliationsSet(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str,
+        affiliations: Dict[jid.JID, str],
+    ):
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("set")
+        iq_elt['to'] = service.full()
+        affiliations_elt = iq_elt.addElement((NS_FIS_AFFILIATION, "affiliations"))
+        if namespace:
+            affiliations_elt["namespace"] = namespace
+        affiliations_elt["path"] = path
+        for entity_jid, affiliation in affiliations.items():
+            affiliation_elt = affiliations_elt.addElement('affiliation')
+            affiliation_elt['jid'] = entity_jid.full()
+            affiliation_elt['affiliation'] = affiliation
+        await iq_elt.send()
+
+    def _on_component_affiliations_get(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_affiliations_get(client, iq_elt))
+
+    async def on_component_affiliations_get(self, client, iq_elt):
+        try:
+            (
+                from_jid, affiliations_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            # if root path is requested, we only get owner affiliation
+            peer_jid, owner = client.get_owner_and_peer(iq_elt)
+            is_owner = peer_jid.userhostJID() == owner
+            affiliations = {owner: 'owner'}
+        except exceptions.NotFound:
+            client.sendError(iq_elt, "item-not-found")
+            return
+        except Exception as e:
+            client.sendError(iq_elt, "internal-server-error", str(e))
+            return
+        else:
+            from_jid_bare = from_jid.userhostJID()
+            is_owner = from_jid_bare == file_data.get('owner')
+            affiliations = self.host.memory.get_file_affiliations(file_data)
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        affiliations_elt = iq_result_elt.addElement((NS_FIS_AFFILIATION, 'affiliations'))
+        for entity_jid, affiliation in affiliations.items():
+            if not is_owner and entity_jid.userhostJID() != from_jid_bare:
+                # only onwer can get all affiliations
+                continue
+            affiliation_elt = affiliations_elt.addElement('affiliation')
+            affiliation_elt['jid'] = entity_jid.userhost()
+            affiliation_elt['affiliation'] = affiliation
+        client.send(iq_result_elt)
+
+    def _on_component_affiliations_set(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_affiliations_set(client, iq_elt))
+
+    async def on_component_affiliations_set(self, client, iq_elt):
+        try:
+            (
+                from_jid, affiliations_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "affiliations", NS_FIS_AFFILIATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+
+        if from_jid.userhostJID() != file_data['owner']:
+            log.warning(
+                f"{from_jid} tried to modify {path} affiliations while the owner is "
+                f"{file_data['owner']}"
+            )
+            client.sendError(iq_elt, 'forbidden')
+            return
+
+        try:
+            affiliations = {
+                jid.JID(e['jid']): e['affiliation']
+                for e in affiliations_elt.elements(NS_FIS_AFFILIATION, 'affiliation')
+            }
+        except (KeyError, RuntimeError):
+                log.warning(
+                    f"invalid affiliation element: {affiliations_elt.toXml()}"
+                )
+                client.sendError(iq_elt, 'bad-request', "invalid affiliation element")
+                return
+        except Exception as e:
+                log.error(
+                    f"unexepected exception while setting affiliation element: {e}\n"
+                    f"{affiliations_elt.toXml()}"
+                )
+                client.sendError(iq_elt, 'internal-server-error', f"{e}")
+                return
+
+        await self.host.memory.set_file_affiliations(client, file_data, affiliations)
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    # configuration
+
+    def _configuration_get(self, service_jid_s, namespace, path, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        d = defer.ensureDeferred(self.configuration_get(
+            client, service, namespace or None, path))
+        d.addCallback(
+            lambda configuration: {
+                str(entity): affiliation for entity, affiliation in configuration.items()
+            }
+        )
+        return d
+
+    async def configuration_get(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str
+    ) -> Dict[str, str]:
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("get")
+        iq_elt['to'] = service.full()
+        configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
+        if namespace:
+            configuration_elt["namespace"] = namespace
+        configuration_elt["path"] = path
+        iq_result_elt = await iq_elt.send()
+        try:
+            configuration_elt = next(iq_result_elt.elements(NS_FIS_CONFIGURATION, "configuration"))
+        except StopIteration:
+            raise exceptions.DataError(f"Invalid result to configuration request: {iq_result_elt.toXml()}")
+
+        form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
+        configuration = {f.var: f.value for f in form.fields.values()}
+
+        return configuration
+
+    def _configuration_set(self, service_jid_s, namespace, path, configuration, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        return defer.ensureDeferred(self.configuration_set(
+            client, service, namespace or None, path, configuration))
+
+    async def configuration_set(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str,
+        configuration: Dict[str, str],
+    ):
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("set")
+        iq_elt['to'] = service.full()
+        configuration_elt = iq_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
+        if namespace:
+            configuration_elt["namespace"] = namespace
+        configuration_elt["path"] = path
+        form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
+        form.makeFields(configuration)
+        configuration_elt.addChild(form.toElement())
+        await iq_elt.send()
+
+    def _on_component_configuration_get(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_configuration_get(client, iq_elt))
+
+    async def on_component_configuration_get(self, client, iq_elt):
+        try:
+            (
+                from_jid, configuration_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+        try:
+            access_type = file_data['access'][C.ACCESS_PERM_READ]['type']
+        except KeyError:
+            access_model = 'whitelist'
+        else:
+            access_model = 'open' if access_type == C.ACCESS_TYPE_PUBLIC else 'whitelist'
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        configuration_elt = iq_result_elt.addElement((NS_FIS_CONFIGURATION, 'configuration'))
+        form = data_form.Form(formType="form", formNamespace=NS_FIS_CONFIGURATION)
+        form.makeFields({'access_model': access_model})
+        configuration_elt.addChild(form.toElement())
+        client.send(iq_result_elt)
+
+    async def _set_configuration(self, client, configuration_elt, file_data):
+        form = data_form.findForm(configuration_elt, NS_FIS_CONFIGURATION)
+        for name, value in form.items():
+            if name == 'access_model':
+                await self.host.memory.set_file_access_model(client, file_data, value)
+            else:
+                # TODO: send a IQ error?
+                log.warning(
+                    f"Trying to set a not implemented configuration option: {name}")
+
+    def _on_component_configuration_set(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_configuration_set(client, iq_elt))
+
+    async def on_component_configuration_set(self, client, iq_elt):
+        try:
+            (
+                from_jid, configuration_elt, path, namespace, file_data
+            ) = await self._parse_element(client, iq_elt, "configuration", NS_FIS_CONFIGURATION)
+        except exceptions.CancelError:
+            return
+        except RootPathException:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+
+        from_jid_bare = from_jid.userhostJID()
+        is_owner = from_jid_bare == file_data.get('owner')
+        if not is_owner:
+            log.warning(
+                f"{from_jid} tried to modify {path} configuration while the owner is "
+                f"{file_data['owner']}"
+            )
+            client.sendError(iq_elt, 'forbidden')
+            return
+
+        await self._set_configuration(client, configuration_elt, file_data)
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    # directory creation
+
+    def _create_dir(self, service_jid_s, namespace, path, configuration, profile):
+        client = self.host.get_client(profile)
+        service = jid.JID(service_jid_s)
+        return defer.ensureDeferred(self.create_dir(
+            client, service, namespace or None, path, configuration or None))
+
+    async def create_dir(
+        self,
+        client: SatXMPPEntity,
+        service: jid.JID,
+        namespace: Optional[str],
+        path: str,
+        configuration: Optional[Dict[str, str]],
+    ):
+        if not path:
+            raise ValueError(f"invalid path: {path!r}")
+        iq_elt = client.IQ("set")
+        iq_elt['to'] = service.full()
+        create_dir_elt = iq_elt.addElement((NS_FIS_CREATE, "dir"))
+        if namespace:
+            create_dir_elt["namespace"] = namespace
+        create_dir_elt["path"] = path
+        if configuration:
+            configuration_elt = create_dir_elt.addElement((NS_FIS_CONFIGURATION, "configuration"))
+            form = data_form.Form(formType="submit", formNamespace=NS_FIS_CONFIGURATION)
+            form.makeFields(configuration)
+            configuration_elt.addChild(form.toElement())
+        await iq_elt.send()
+
+    def _on_component_create_dir(self, iq_elt, client):
+        iq_elt.handled = True
+        defer.ensureDeferred(self.on_component_create_dir(client, iq_elt))
+
+    async def on_component_create_dir(self, client, iq_elt):
+        peer_jid, owner = client.get_owner_and_peer(iq_elt)
+        if peer_jid.host not in client._file_sharing_allowed_hosts:
+            client.sendError(iq_elt, 'forbidden')
+            return
+        create_dir_elt = next(iq_elt.elements(NS_FIS_CREATE, "dir"))
+        namespace = create_dir_elt.getAttribute('namespace')
+        path = Path("/", create_dir_elt['path'])
+        if len(path.parts) < 2:
+            client.sendError(iq_elt, 'bad-request', "Root path can't be used")
+            return
+        # for root directories, we check permission here
+        if len(path.parts) == 2 and owner != peer_jid.userhostJID():
+            log.warning(
+                f"{peer_jid} is trying to create a dir at {owner}'s repository:\n"
+                f"path: {path}\nnamespace: {namespace!r}"
+            )
+            client.sendError(iq_elt, 'forbidden', "You can't create a directory there")
+            return
+        # when going further into the path, the permissions will be checked by get_files
+        files_data = await self.host.memory.get_files(
+            client,
+            peer_jid=peer_jid,
+            path=path.parent,
+            namespace=namespace,
+            owner=owner,
+        )
+        if path.name in [d['name'] for d in files_data]:
+            log.warning(
+                f"Conflict when trying to create a directory (from: {peer_jid} "
+                f"namespace: {namespace!r} path: {path!r})"
+            )
+            client.sendError(
+                iq_elt, 'conflict', "there is already a file or dir at this path")
+            return
+
+        try:
+            configuration_elt = next(
+                create_dir_elt.elements(NS_FIS_CONFIGURATION, 'configuration'))
+        except StopIteration:
+            configuration_elt = None
+
+        await self.host.memory.set_file(
+            client,
+            path.name,
+            path=path.parent,
+            type_=C.FILE_TYPE_DIRECTORY,
+            namespace=namespace,
+            owner=owner,
+            peer_jid=peer_jid
+        )
+
+        if configuration_elt is not None:
+            file_data = (await self.host.memory.get_files(
+                client,
+                peer_jid=peer_jid,
+                path=path.parent,
+                name=path.name,
+                namespace=namespace,
+                owner=owner,
+            ))[0]
+
+            await self._set_configuration(client, configuration_elt, file_data)
+
+        iq_result_elt = xmlstream.toResponse(iq_elt, "result")
+        client.send(iq_result_elt)
+
+    # file methods #
+
+    def _serialize_data(self, files_data):
+        for file_data in files_data:
+            for key, value in file_data.items():
+                file_data[key] = (
+                    json.dumps(value) if key in ("extra",) else str(value)
+                )
+        return files_data
+
+    def _list_files(self, target_jid, path, extra, profile):
+        client = self.host.get_client(profile)
+        target_jid = client.jid if not target_jid else jid.JID(target_jid)
+        d = defer.ensureDeferred(self.list_files(client, target_jid, path or None))
+        d.addCallback(self._serialize_data)
+        return d
+
+    async def list_files(self, client, peer_jid, path=None, extra=None):
+        """List file shared by an entity
+
+        @param peer_jid(jid.JID): jid of the sharing entity
+        @param path(unicode, None): path to the directory containing shared files
+            None to get root directories
+        @param extra(dict, None): extra data
+        @return list(dict): shared files
+        """
+        iq_elt = client.IQ("get")
+        iq_elt["to"] = peer_jid.full()
+        query_elt = iq_elt.addElement((NS_FIS, "query"))
+        if path:
+            query_elt["node"] = path
+        iq_result_elt = await iq_elt.send()
+        return await self._parse_result(client, peer_jid, iq_result_elt)
+
+    def _local_shares_get(self, profile):
+        client = self.host.get_client(profile)
+        return self.local_shares_get(client)
+
+    def local_shares_get(self, client):
+        return list(client._XEP_0329_root_node.get_shared_paths().keys())
+
+    def _share_path(self, name, path, access, profile):
+        client = self.host.get_client(profile)
+        access = json.loads(access)
+        return self.share_path(client, name or None, path, access)
+
+    def share_path(self, client, name, path, access):
+        if client.is_component:
+            raise exceptions.ClientTypeError
+        if not os.path.exists(path):
+            raise ValueError(_("This path doesn't exist!"))
+        if not path or not path.strip(" /"):
+            raise ValueError(_("A path need to be specified"))
+        if not isinstance(access, dict):
+            raise ValueError(_("access must be a dict"))
+
+        node = client._XEP_0329_root_node
+        node_type = TYPE_PATH
+        if os.path.isfile(path):
+            # we have a single file, the workflow is diferrent as we store all single
+            # files in the same dir
+            node = node.get_or_create(SINGLE_FILES_DIR)
+
+        if not name:
+            name = os.path.basename(path.rstrip(" /"))
+            if not name:
+                raise exceptions.InternalError(_("Can't find a proper name"))
+
+        if name in node or name == SINGLE_FILES_DIR:
+            idx = 1
+            new_name = name + "_" + str(idx)
+            while new_name in node:
+                idx += 1
+                new_name = name + "_" + str(idx)
+            name = new_name
+            log.info(_(
+                "A directory with this name is already shared, renamed to {new_name} "
+                "[{profile}]".format( new_name=new_name, profile=client.profile)))
+
+        ShareNode(name=name, parent=node, type_=node_type, access=access, path=path)
+        self.host.bridge.fis_shared_path_new(path, name, client.profile)
+        return name
+
+    def _unshare_path(self, path, profile):
+        client = self.host.get_client(profile)
+        return self.unshare_path(client, path)
+
+    def unshare_path(self, client, path):
+        nodes = client._XEP_0329_root_node.find_by_local_path(path)
+        for node in nodes:
+            node.remove_from_parent()
+        self.host.bridge.fis_shared_path_removed(path, client.profile)
+
+
+@implementer(iwokkel.IDisco)
+class XEP_0329_handler(xmlstream.XMPPHandler):
+
+    def __init__(self, plugin_parent):
+        self.plugin_parent = plugin_parent
+        self.host = plugin_parent.host
+
+    def connectionInitialized(self):
+        if self.parent.is_component:
+            self.xmlstream.addObserver(
+                IQ_FIS_REQUEST, self.plugin_parent.on_component_request, client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_AFFILIATION_GET,
+                self.plugin_parent._on_component_affiliations_get,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_AFFILIATION_SET,
+                self.plugin_parent._on_component_affiliations_set,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_CONFIGURATION_GET,
+                self.plugin_parent._on_component_configuration_get,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_CONFIGURATION_SET,
+                self.plugin_parent._on_component_configuration_set,
+                client=self.parent
+            )
+            self.xmlstream.addObserver(
+                IQ_FIS_CREATE_DIR,
+                self.plugin_parent._on_component_create_dir,
+                client=self.parent
+            )
+        else:
+            self.xmlstream.addObserver(
+                IQ_FIS_REQUEST, self.plugin_parent.on_request, client=self.parent
+            )
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
+        return [disco.DiscoFeature(NS_FIS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=""):
+        return []