diff sat/plugins/plugin_xep_0329.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/plugins/plugin_xep_0329.py@8d82a62fa098
children 282d1314d574
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/sat/plugins/plugin_xep_0329.py	Mon Apr 02 19:44:50 2018 +0200
@@ -0,0 +1,565 @@
+#!/usr/bin/env python2
+# -*- coding: utf-8 -*-
+
+# SAT plugin for File Information Sharing (XEP-0329)
+# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
+
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU Affero General Public License for more details.
+
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+from sat.core.i18n import _
+from sat.core import exceptions
+from sat.core.constants import Const as C
+from sat.core.log import getLogger
+log = getLogger(__name__)
+from sat.tools import stream
+from sat.tools.common import regex
+from wokkel import disco, iwokkel
+from zope.interface import implements
+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
+import mimetypes
+import json
+import os
+
+
+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-0234", "XEP-0300"],
+    C.PI_MAIN: "XEP_0329",
+    C.PI_HANDLER: "yes",
+    C.PI_DESCRIPTION: _(u"""Implementation of File Information Sharing""")
+}
+
+NS_FIS = 'urn:xmpp:fis:0'
+
+IQ_FIS_REQUEST = C.IQ_GET + '/query[@xmlns="' + NS_FIS + '"]'
+SINGLE_FILES_DIR = u"files"
+TYPE_VIRTUAL= u'virtual'
+TYPE_PATH = u'path'
+SHARE_TYPES  = (TYPE_PATH, TYPE_VIRTUAL)
+KEY_TYPE = u'type'
+
+
+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 == u'..' or u'/' in name or u'\\' in name:
+                log.warning(_(u'path change chars found in name [{name}], hack attempt?').format(name=name))
+                if name == u'..':
+                    name = u'--'
+                else:
+                    name = regex.pathEscape(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(_(u"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 iteritems(self):
+        return self.children.iteritems()
+
+    def getOrCreate(self, name, type_=TYPE_VIRTUAL, access=None):
+        """get a node or create a virtual one 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(_(u"a node can't have several parents"))
+        node.parent = self
+        self.children[node.name] = node
+
+    def _checkNodePermission(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 = {u'access':self.access, u'owner': client.jid.userhostJID()}
+        try:
+            self.host.memory.checkFilePermission(file_data, peer_jid, perms)
+        except exceptions.PermissionError:
+            return False
+        else:
+            return True
+
+    def checkPermissions(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._checkNodePermission(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 = filter(None, path.split(u'/'))
+
+        if u'..' in path_elts:
+            log.warning(_(u'parent dir ("..") found in path, hack attempt? path is {path} [{profile}]').format(
+                path=path, profile=client.profile))
+            raise exceptions.PermissionError(u"illegal path elements")
+
+        if not path_elts:
+            raise exceptions.DataError(_(u'path is invalid: {path}').format(path=path))
+
+        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.checkPermissions(client, peer_jid, perms = perms):
+            raise exceptions.PermissionError(u"permission denied")
+
+        return node, u'/'.join(path_elts)
+
+
+class XEP_0329(object):
+
+    def __init__(self, host):
+        log.info(_("File Information Sharing initialization"))
+        self.host = host
+        ShareNode.host = host
+        self._h = host.plugins['XEP-0300']
+        self._jf = host.plugins['XEP-0234']
+        host.bridge.addMethod("FISList", ".plugin", in_sign='ssa{ss}s', out_sign='aa{ss}', method=self._listFiles, async=True)
+        host.bridge.addMethod("FISSharePath", ".plugin", in_sign='ssss', out_sign='s', method=self._sharePath)
+        host.trigger.add("XEP-0234_fileSendingRequest", self._fileSendingRequestTrigger)
+        host.registerNamespace('fis', NS_FIS)
+
+    def getHandler(self, client):
+        return XEP_0329_handler(self)
+
+    def profileConnected(self, client):
+        if not client.is_component:
+            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 _fileSendingRequestTrigger(self, client, session, content_data, content_name, file_data, file_elt):
+        """this trigger check that a requested file is available, and fill suitable data if so
+
+        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[u'name']
+        except KeyError:
+            return True, None
+        assert u'/' not in name
+
+        path = file_data.get(u'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[u'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(_(u'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(u'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.iteritems():
+                if True:  # FIXME: filters are here
+                    break
+            else:
+                return True, None
+            parent_node = shared_file[u'parent']
+            if not parent_node.checkPermissions(client, session[u'peer_jid']):
+                log.warning(_(u"{peer_jid} requested a file (s)he can't access [{profile}]").format(
+                    peer_jid = session[u'peer_jid'], profile = client.profile))
+                return True, None
+            size = shared_file[u'size']
+
+        file_data[u'size'] = size
+        file_elt.addElement(u'size', content=unicode(size))
+        hash_algo = file_data[u'hash_algo'] = self._h.getDefaultAlgo()
+        hasher = file_data[u'hash_hasher'] = self._h.getHasher(hash_algo)
+        file_elt.addChild(self._h.buildHashUsedElt(hash_algo))
+        content_data['stream_object'] = stream.FileStreamObject(
+            self.host,
+            client,
+            path,
+            uid=self._jf.getProgressId(session, content_name),
+            size=size,
+            data_cb=lambda data: hasher.update(data),
+            )
+        return False, True
+
+    # common methods
+
+    def _requestHandler(self, client, iq_elt, root_nodes_cb, files_from_node_cb):
+        iq_elt.handled = True
+        owner = jid.JID(iq_elt['from']).userhostJID()
+        node = iq_elt.query.getAttribute('node')
+        if not node:
+            d = defer.maybeDeferred(root_nodes_cb, client, iq_elt, owner)
+        else:
+            d = defer.maybeDeferred(files_from_node_cb, client, iq_elt, owner, node)
+        d.addErrback(lambda failure_: log.error(_(u"error while retrieving files: {msg}").format(msg=failure_)))
+
+    def _iqError(self, client, iq_elt, condition="item-not-found"):
+        error_elt = jabber_error.StanzaError(condition).toResponse(iq_elt)
+        client.send(error_elt)
+
+    # client
+
+    def _addPathData(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.buildFileElement(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 _pathNodeHandler(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._iqError(client, iq_elt)
+        elif os.path.isfile(path):
+            self._addPathData(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(_(u"ignoring invalid unicode name ({name}): {msg}").format(
+                        name = name.decode('utf-8', 'replace'),
+                        msg = e))
+                    continue
+                full_path = os.path.join(path, name)
+                self._addPathData(client, query_elt, full_path, node)
+
+    def _virtualNodeHandler(self, client, peer_jid, iq_elt, query_elt, node):
+        """Fill query_elt for virtual nodes"""
+        for name, child_node in node.iteritems():
+            if not child_node.checkPermissions(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._addPathData(client, query_elt, child_node.path, child_node)
+            else:
+                raise exceptions.InternalError(_(u'unexpected type: {type}').format(type=node_type))
+
+    def _getRootNodesCb(self, client, iq_elt, owner):
+        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.iteritems():
+            if not node.checkPermissions(client, peer_jid, check_parents=False):
+                continue
+            directory_elt = query_elt.addElement('directory')
+            directory_elt['name'] = name
+        client.send(iq_result_elt)
+
+    def _getFilesFromNodeCb(self, client, iq_elt, owner, node_path):
+        """Main method to retrieve files/directories from a node_path"""
+        peer_jid = jid.JID(iq_elt[u'from'])
+        try:
+            node, path = ShareNode.find(client, node_path, peer_jid)
+        except (exceptions.PermissionError, exceptions.NotFound):
+            return self._iqError(client, iq_elt)
+        except exceptions.DataError:
+            return self._iqError(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[u'node'] = node_path
+
+        # we now fill query_elt according to node_type
+        if node_type == TYPE_PATH:
+            # it's a physical path
+            self._pathNodeHandler(client, iq_elt, query_elt, node, path)
+        elif node_type == TYPE_VIRTUAL:
+            assert not path
+            self._virtualNodeHandler(client, peer_jid, iq_elt, query_elt, node)
+        else:
+            raise exceptions.InternalError(_(u'unknown node type: {type}').format(type=node_type))
+
+        client.send(iq_result_elt)
+
+    def onRequest(self, iq_elt, client):
+        return self._requestHandler(client, iq_elt, self._getRootNodesCb, self._getFilesFromNodeCb)
+
+    # Component
+
+    @defer.inlineCallbacks
+    def _compGetRootNodesCb(self, client, iq_elt, owner):
+        peer_jid = jid.JID(iq_elt['from'])
+        files_data = yield self.host.memory.getFiles(client, peer_jid=peer_jid, parent=u'',
+                                                     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[u'name']
+            directory_elt = query_elt.addElement(u'directory')
+            directory_elt[u'name'] = name
+        client.send(iq_result_elt)
+
+    @defer.inlineCallbacks
+    def _compGetFilesFromNodeCb(self, client, iq_elt, owner, 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 = jid.JID(iq_elt['from'])
+        try:
+            files_data = yield self.host.memory.getFiles(client, peer_jid=peer_jid, path=node_path, owner=owner)
+        except exceptions.NotFound:
+            self._iqError(client, iq_elt)
+            return
+        iq_result_elt = xmlstream.toResponse(iq_elt, 'result')
+        query_elt = iq_result_elt.addElement((NS_FIS, 'query'))
+        query_elt[u'node'] = node_path
+        if not self.host.trigger.point(u'XEP-0329_compGetFilesFromNode', client, iq_elt, owner, node_path, files_data):
+            return
+        for file_data in files_data:
+            file_elt = self._jf.buildFileElementFromDict(file_data,
+                                                         modified=file_data.get(u'modified', file_data[u'created']))
+            query_elt.addChild(file_elt)
+        client.send(iq_result_elt)
+
+    def onComponentRequest(self, iq_elt, client):
+        return self._requestHandler(client, iq_elt, self._compGetRootNodesCb, self._compGetFilesFromNodeCb)
+
+    def _parseResult(self, 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 = self._jf.parseFileElement(elt)
+                except exceptions.DataError:
+                    continue
+                file_data[u'type'] = C.FILE_TYPE_FILE
+            elif elt.name == 'directory' and elt.uri == NS_FIS:
+                # we have a directory
+
+                file_data = {'name': elt['name'], 'type': C.FILE_TYPE_DIRECTORY}
+            else:
+                log.warning(_(u"unexpected element, ignoring: {elt}").format(elt=elt.toXml()))
+                continue
+            files.append(file_data)
+        return files
+
+    # file methods #
+
+    def _serializeData(self, files_data):
+        for file_data in files_data:
+            for key, value in file_data.iteritems():
+                file_data[key] = json.dumps(value) if key in ('extra',) else unicode(value)
+        return files_data
+
+    def _listFiles(self, target_jid, path, extra, profile):
+        client = self.host.getClient(profile)
+        target_jid = client.jid.userhostJID() if not target_jid else jid.JID(target_jid)
+        d = self.listFiles(client, target_jid, path or None)
+        d.addCallback(self._serializeData)
+        return d
+
+    def listFiles(self, client, target_jid, path=None, extra=None):
+        """List file shared by an entity
+
+        @param target_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'] = target_jid.full()
+        query_elt = iq_elt.addElement((NS_FIS, 'query'))
+        if path:
+            query_elt['node'] = path
+        d = iq_elt.send()
+        d.addCallback(self._parseResult)
+        return d
+
+    def _sharePath(self, name, path, access, profile):
+        client = self.host.getClient(profile)
+        access= json.loads(access)
+        return self.sharePath(client, name or None, path, access)
+
+    def sharePath(self, client, name, path, access):
+        if client.is_component:
+            raise exceptions.ClientTypeError
+        if not os.path.exists(path):
+            raise ValueError(_(u"This path doesn't exist!"))
+        if not path or not path.strip(u' /'):
+            raise ValueError(_(u"A path need to be specified"))
+
+        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.getOrCreate(SINGLE_FILES_DIR)
+
+        if not name:
+            name = os.path.basename(path.rstrip(u' /'))
+            if not name:
+                raise exceptions.InternalError(_(u"Can't find a proper name"))
+
+        if not isinstance(access, dict):
+            raise ValueError(_(u'access must be a dict'))
+
+        if name in node or name == SINGLE_FILES_DIR:
+            idx = 1
+            new_name = name + '_' + unicode(idx)
+            while new_name in node:
+                idx += 1
+                new_name = name + '_' + unicode(idx)
+            name = new_name
+            log.info(_(u"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)
+        return name
+
+
+class XEP_0329_handler(xmlstream.XMPPHandler):
+    implements(iwokkel.IDisco)
+
+    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.onComponentRequest, client=self.parent)
+        else:
+            self.xmlstream.addObserver(IQ_FIS_REQUEST, self.plugin_parent.onRequest, client=self.parent)
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+        return [disco.DiscoFeature(NS_FIS)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+        return []