Mercurial > libervia-backend
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 []