Mercurial > libervia-backend
diff src/plugins/plugin_xep_0234.py @ 2502:7ad5f2c4e34a
XEP-0065,XEP-0096,XEP-0166,XEP-0235,XEP-0300: file transfer improvments:
huge patch sorry :)
many things are improved by this patch, notably:
- updated to last protocol changes (urn:xmpp:jingle:apps:file-transfer:5 and urn:xmpp:hashes:2)
- XEP-0234: file request implementation
- XEP-0234: methods to parse and generate <file> element (can be used by other plugins easily)
- XEP-0234: range data is now in a namedtuple
- path and namespace can be specified when sending/requesting a file (not standard, but needed for file sharing)
- better error/termination handling
- trigger points to handle file requests by other plugins
- preparation to use file plugins with components
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 28 Feb 2018 18:28:39 +0100 |
parents | e2a7bb875957 |
children | 025afb04c10b |
line wrap: on
line diff
--- a/src/plugins/plugin_xep_0234.py Wed Feb 28 18:28:39 2018 +0100 +++ b/src/plugins/plugin_xep_0234.py Wed Feb 28 18:28:39 2018 +0100 @@ -34,14 +34,17 @@ from twisted.internet import defer from twisted.internet import reactor from twisted.internet import error as internet_error +from collections import namedtuple +import regex -NS_JINGLE_FT = 'urn:xmpp:jingle:apps:file-transfer:4' +NS_JINGLE_FT = 'urn:xmpp:jingle:apps:file-transfer:5' PLUGIN_INFO = { C.PI_NAME: "Jingle File Transfer", C.PI_IMPORT_NAME: "XEP-0234", C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0234"], C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0300", "FILE"], C.PI_MAIN: "XEP_0234", @@ -49,6 +52,9 @@ C.PI_DESCRIPTION: _("""Implementation of Jingle File Transfer""") } +EXTRA_ALLOWED = {u'path', u'namespace', u'file_desc', u'file_hash'} +Range = namedtuple('Range', ('offset', 'length')) + class XEP_0234(object): # TODO: assure everything is closed when file is sent or session terminate is received @@ -57,17 +63,19 @@ def __init__(self, host): log.info(_("plugin Jingle File Transfer initialization")) self.host = host + host.registerNamespace('jingle-ft', NS_JINGLE_FT) self._j = host.plugins["XEP-0166"] # shortcut to access jingle self._j.registerApplication(NS_JINGLE_FT, self) self._f = host.plugins["FILE"] self._f.register(NS_JINGLE_FT, self.fileJingleSend, priority = 10000, method_name=u"Jingle") self._hash = self.host.plugins["XEP-0300"] - host.bridge.addMethod("fileJingleSend", ".plugin", in_sign='sssss', out_sign='', method=self._fileJingleSend) + host.bridge.addMethod("fileJingleSend", ".plugin", in_sign='ssssa{ss}s', out_sign='', method=self._fileJingleSend, async=True) + host.bridge.addMethod("fileJingleRequest", ".plugin", in_sign='sssssa{ss}s', out_sign='s', method=self._fileJingleRequest, async=True) def getHandler(self, client): return XEP_0234_handler() - def _getProgressId(self, session, content_name): + def getProgressId(self, session, content_name): """Return a unique progress ID @param session(dict): jingle session @@ -76,11 +84,154 @@ """ return u'{}_{}'.format(session['id'], content_name) - def _fileJingleSend(self, peer_jid, filepath, name="", file_desc="", profile=C.PROF_KEY_NONE): + # generic methods + + def buildFileElement(self, name, file_hash=None, hash_algo=None, size=None, media_type=None, desc=None, + date=None, range_offset=None, range_length=None, path=None, namespace=None, file_elt=None): + """Generate a <file> element with available metadata + + @param file_hash(unicode, None): hash of the file + empty string to set <hash-used/> element + @param hash_algo(unicode, None): hash algorithm used + if file_hash is None and hash_algo is set, a <hash-used/> element will be generated + @param range_offset(int, None): offset where transfer must start + use -1 to add an empty <range/> element + @param date(int, unicode, None): date of last modification + 0 to use current date + int to use an unix timestamp + else must be an unicode string which will be used as it (it must be an XMPP time) + @param file_elt(domish.Element, None): element to use + None to create a new one + @return (domish.Element): generated element + """ + if file_elt is None: + file_elt = domish.Element((NS_JINGLE_FT, u'file')) + for name, value in ((u'name', name), (u'size', size), ('media-type', media_type), + (u'desc', desc), (u'path', path), (u'namespace', namespace)): + if value is not None: + file_elt.addElement(name, content=unicode(value)) + if date is not None: + if isinstance(date, int): + file_elt.addElement(u'date', utils.xmpp_date(date or None)) + else: + file_elt.addElement(u'date', date) + if range_offset or range_length: + range_elt = file_elt.addElement(u'range') + if range_offset is not None and range_offset != -1: + range_elt[u'offset'] = range_offset + if range_length is not None: + range_elt[u'length'] = range_length + if file_hash is not None: + if not file_hash: + file_elt.addChild(self._hash.buildHashUsedElt()) + else: + file_elt.addChild(self._hash.buildHashElt(file_hash, hash_algo)) + elif hash_algo is not None: + file_elt.addChild(self._hash.buildHashUsedElt(hash_algo)) + return file_elt + + def buildFileElementFromDict(self, file_data, file_elt = None, **kwargs): + """like buildFileElement but get values from a file_data dict + + @param file_data(dict): metadata to use + @param **kwargs: data to override + """ + if kwargs: + file_data = file_data.copy() + file_data.update(kwargs) + (name, file_hash, hash_algo, size, media_type, + desc, date, path, namespace) = (file_data.get(u'name'), + file_data.get(u'file_hash'), + file_data.get(u'hash_algo'), + file_data.get(u'size'), + file_data.get(u'media-type'), + file_data.get(u'desc'), + file_data.get(u'date'), + file_data.get(u'path'), + file_data.get(u'namespace')) + try: + range_offset, range_length = file_data[u'range'] + except KeyError: + range_offset = range_length = None + return self. buildFileElement(name = name, file_hash = file_hash, hash_algo = hash_algo, size = size, + media_type = media_type, desc = desc, date = date, + range_offset = range_offset, range_length = range_length, path = path, + namespace = namespace, file_elt = file_elt) + + + def parseFileElement(self, file_elt, file_data=None, given=False, parent_elt=None): + """Parse a <file> element and file dictionary accordingly + + @param file_data(dict, None): dict where the data will be set + following keys will be set (and overwritten if they already exist): + name, file_hash, hash_algo, size, media-type, desc, path, namespace, range + if None, a new dict is created + @param given(bool): if True, prefix hash key with "given_" + @param parent_elt(domish.Element, None): parent of the file element + if set, file_elt must not be set + @return (dict): file_data + @raise exceptions.NotFound: there is not <file> element in parent_elt + @raise exceptions.DataError: if file_elt uri is not NS_JINGLE_FT + """ + if parent_elt is not None: + if file_elt is not None: + raise exceptions.InternalError(u'file_elt must be None if parent_elt is set') + try: + file_elt = next(parent_elt.elements(NS_JINGLE_FT, u'file')) + except StopIteration: + raise exceptions.NotFound() + else: + if not file_elt or file_elt.uri != NS_JINGLE_FT: + raise exceptions.DataError(u'invalid <file> element: {stanza}'.format(stanza = file_elt.toXml())) + + if file_data is None: + file_data = {} + + for name in (u'name', u'media-type', u'desc', u'path', u'namespace'): + try: + file_data[name] = unicode(file_elt.elements(NS_JINGLE_FT, name).next()) + except StopIteration: + pass + + name = file_data.get(u'name') + if name == u'..': + # we don't want to go to parent dir when joining to a path + name = u'--' + file_data[u'name'] = name + elif name is not None and u'/' in name or u'\\' in name: + file_data[u'name'] = regex.pathEscape(name) + + try: + file_data[u'size'] = int(unicode(file_elt.elements(NS_JINGLE_FT, u'size').next())) + except StopIteration: + pass + + try: + range_elt = file_elt.elements(NS_JINGLE_FT, u'range').next() + except StopIteration: + pass + else: + offset = range_elt.getAttribute('offset') + length = range_elt.getAttribute('length') + file_data[u'range'] = Range(offset=offset, length=length) + + prefix = u'given_' if given else u'' + hash_algo_key, hash_key = u'hash_algo', prefix + u'file_hash' + try: + file_data[hash_algo_key], file_data[hash_key] = self._hash.parseHashElt(file_elt) + except exceptions.NotFound: + pass + + return file_data + + # bridge methods + + def _fileJingleSend(self, peer_jid, filepath, name="", file_desc="", extra=None, profile=C.PROF_KEY_NONE): client = self.host.getClient(profile) - return self.fileJingleSend(client, jid.JID(peer_jid), filepath, name or None, file_desc or None) + return self.fileJingleSend(client, jid.JID(peer_jid), filepath, name or None, file_desc or None, extra or None) - def fileJingleSend(self, client, peer_jid, filepath, name, file_desc=None): + @defer.inlineCallbacks + def fileJingleSend(self, client, peer_jid, filepath, name, file_desc=None, extra=None): """Send a file using jingle file transfer @param peer_jid(jid.JID): destinee jid @@ -90,61 +241,160 @@ @return (D(unicode)): progress id """ progress_id_d = defer.Deferred() - self._j.initiate(client, - peer_jid, - [{'app_ns': NS_JINGLE_FT, - 'senders': self._j.ROLE_INITIATOR, - 'app_kwargs': {'filepath': filepath, - 'name': name, - 'file_desc': file_desc, - 'progress_id_d': progress_id_d}, - }]) - return progress_id_d + if extra is None: + extra = {} + if file_desc is not None: + extra['file_desc'] = file_desc + yield self._j.initiate(client, + peer_jid, + [{'app_ns': NS_JINGLE_FT, + 'senders': self._j.ROLE_INITIATOR, + 'app_kwargs': {'filepath': filepath, + 'name': name, + 'extra': extra, + 'progress_id_d': progress_id_d}, + }]) + progress_id = yield progress_id_d + defer.returnValue(progress_id) + + def _fileJingleRequest(self, peer_jid, filepath, name="", file_hash="", hash_algo="", extra=None, profile=C.PROF_KEY_NONE): + client = self.host.getClient(profile) + return self.fileJingleRequest(client, jid.JID(peer_jid), filepath, name or None, file_hash or None, hash_algo or None, extra or None) + + @defer.inlineCallbacks + def fileJingleRequest(self, client, peer_jid, filepath, name=None, file_hash=None, hash_algo=None, extra=None): + """Request a file using jingle file transfer + + @param peer_jid(jid.JID): destinee jid + @param filepath(str): absolute path of the file + @param name(unicode, None): name of the file + @param file_hash(unicode, None): hash of the file + @return (D(unicode)): progress id + """ + progress_id_d = defer.Deferred() + if extra is None: + extra = {} + if file_hash is not None: + if hash_algo is None: + raise ValueError(_(u"hash_algo must be set if file_hash is set")) + extra['file_hash'] = file_hash + extra['hash_algo'] = hash_algo + else: + if hash_algo is not None: + raise ValueError(_(u"file_hash must be set if hash_algo is set")) + yield self._j.initiate(client, + peer_jid, + [{'app_ns': NS_JINGLE_FT, + 'senders': self._j.ROLE_RESPONDER, + 'app_kwargs': {'filepath': filepath, + 'name': name, + 'extra': extra, + 'progress_id_d': progress_id_d}, + }]) + progress_id = yield progress_id_d + defer.returnValue(progress_id) # jingle callbacks - def jingleSessionInit(self, client, session, content_name, filepath, name, file_desc, progress_id_d): - progress_id_d.callback(self._getProgressId(session, content_name)) + def jingleSessionInit(self, client, session, content_name, filepath, name, extra, progress_id_d): + if extra is None: + extra = {} + else: + if not EXTRA_ALLOWED.issuperset(extra): + raise ValueError(_(u"only the following keys are allowed in extra: {keys}").format( + keys=u', '.join(EXTRA_ALLOWED))) + progress_id_d.callback(self.getProgressId(session, content_name)) content_data = session['contents'][content_name] application_data = content_data['application_data'] assert 'file_path' not in application_data application_data['file_path'] = filepath file_data = application_data['file_data'] = {} - file_data['date'] = utils.xmpp_date() - file_data['desc'] = file_desc or '' - file_data['media-type'] = "application/octet-stream" # TODO - file_data['name'] = os.path.basename(filepath) if name is None else name - file_data['size'] = os.path.getsize(filepath) desc_elt = domish.Element((NS_JINGLE_FT, 'description')) file_elt = desc_elt.addElement("file") - for name in ('date', 'desc', 'media-type', 'name', 'size'): - file_elt.addElement(name, content=unicode(file_data[name])) - file_elt.addElement("range") # TODO - file_elt.addChild(self._hash.buildHashElt()) + + if content_data[u'senders'] == self._j.ROLE_INITIATOR: + # we send a file + file_data[u'date'] = utils.xmpp_date() + file_data[u'desc'] = extra.pop(u'file_desc', u'') + file_data[u'media-type'] = "application/octet-stream" # TODO + file_data[u'name'] = os.path.basename(filepath) if name is None else name + file_data[u'size'] = os.path.getsize(filepath) + if u'namespace' in extra: + file_data[u'namespace'] = extra[u'namespace'] + if u'path' in extra: + file_data[u'path'] = extra[u'path'] + self.buildFileElementFromDict(file_data, file_elt=file_elt, file_hash=u'') + file_elt.addElement("range") # TODO + else: + # we request a file + file_hash = extra.pop(u'file_hash', u'') + if not name and not file_hash: + raise ValueError(_(u'you need to provide at least name or file hash')) + if name: + file_data[u'name'] = name + if file_hash: + file_data[u'file_hash'] = file_hash + file_data[u'hash_algo'] = extra[u'hash_algo'] + else: + file_data[u'hash_algo'] = self._hash.getDefaultAlgo() + if u'namespace' in extra: + file_data[u'namespace'] = extra[u'namespace'] + if u'path' in extra: + file_data[u'path'] = extra[u'path'] + self.buildFileElementFromDict(file_data, file_elt=file_elt) + return desc_elt def jingleRequestConfirmation(self, client, action, session, content_name, desc_elt): """This method request confirmation for a jingle session""" content_data = session['contents'][content_name] - if content_data['senders'] not in (self._j.ROLE_INITIATOR, self._j.ROLE_RESPONDER): + senders = content_data[u'senders'] + if senders not in (self._j.ROLE_INITIATOR, self._j.ROLE_RESPONDER): log.warning(u"Bad sender, assuming initiator") - content_data['senders'] = self._j.ROLE_INITIATOR + senders = content_data[u'senders'] = self._j.ROLE_INITIATOR # first we grab file informations try: file_elt = desc_elt.elements(NS_JINGLE_FT, 'file').next() except StopIteration: raise failure.Failure(exceptions.DataError) - file_data = {'progress_id': self._getProgressId(session, content_name)} - for name in ('date', 'desc', 'media-type', 'name', 'range', 'size'): + file_data = {'progress_id': self.getProgressId(session, content_name)} + + if senders == self._j.ROLE_RESPONDER: + # we send the file + return self._fileSendingRequestConf(client, session, content_data, content_name, file_data, file_elt) + else: + # we receive the file + return self._fileReceivingRequestConf(client, session, content_data, content_name, file_data, file_elt) + + @defer.inlineCallbacks + def _fileSendingRequestConf(self, client, session, content_data, content_name, file_data, file_elt): + """parse file_elt, and handle file retrieving/permission checking""" + self.parseFileElement(file_elt, file_data) + content_data['application_data']['file_data'] = file_data + finished_d = content_data['finished_d'] = defer.Deferred() + + # confirmed_d is a deferred returning confimed value (only used if cont is False) + cont, confirmed_d = self.host.trigger.returnPoint("XEP-0234_fileSendingRequest", client, session, content_data, content_name, file_data, file_elt) + if not cont: + confirmed = yield confirmed_d + if confirmed: + args = [client, session, content_name, content_data] + finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) + defer.returnValue(confirmed) + + log.warning(_(u'File continue is not implemented yet')) + defer.returnValue(False) + + def _fileReceivingRequestConf(self, client, session, content_data, content_name, file_data, file_elt): + """parse file_elt, and handle user permission/file opening""" + self.parseFileElement(file_elt, file_data, given=True) + try: + hash_algo, file_data['given_file_hash'] = self._hash.parseHashElt(file_elt) + except exceptions.NotFound: try: - file_data[name] = unicode(file_elt.elements(NS_JINGLE_FT, name).next()) - except StopIteration: - file_data[name] = '' - - try: - hash_algo, file_data['hash_given'] = self._hash.parseHashElt(file_elt) - except exceptions.NotFound: - raise failure.Failure(exceptions.DataError) + hash_algo = self._hash.parseHashUsedElt(file_elt) + except exceptions.NotFound: + raise failure.Failure(exceptions.DataError) if hash_algo is not None: file_data['hash_algo'] = hash_algo @@ -166,11 +416,12 @@ # now we actualy request permission to user def gotConfirmation(confirmed): if confirmed: - finished_d = content_data['finished_d'] = defer.Deferred() args = [client, session, content_name, content_data] finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) return confirmed + # deferred to track end of transfer + finished_d = content_data['finished_d'] = defer.Deferred() d = self._f.getDestDir(client, session['peer_jid'], content_data, file_data, stream_object=True) d.addCallback(gotConfirmation) return d @@ -186,23 +437,47 @@ file_elt.elements(NS_JINGLE_FT, 'range').next() except StopIteration: # initiator doesn't manage <range>, but we do so we advertise it + #Â FIXME: to be checked log.debug("adding <range> element") file_elt.addElement('range') elif action == self._j.A_SESSION_ACCEPT: assert not 'stream_object' in content_data file_data = application_data['file_data'] file_path = application_data['file_path'] - size = file_data['size'] - # XXX: hash security is not critical here, so we just take the higher mandatory one - hasher = file_data['hash_hasher'] = self._hash.getHasher('sha-256') - content_data['stream_object'] = stream.FileStreamObject( - self.host, - client, - file_path, - uid=self._getProgressId(session, content_name), - size=size, - data_cb=lambda data: hasher.update(data), - ) + senders = content_data[u'senders'] + if senders != session[u'role']: + # we are receiving the file + try: + # did the responder specified the size of the file? + file_elt = next(desc_elt.elements(NS_JINGLE_FT, u'file')) + size_elt = next(file_elt.elements(NS_JINGLE_FT, u'size')) + size = int(unicode(size_elt)) + except (StopIteration, ValueError): + size = None + # XXX: hash security is not critical here, so we just take the higher mandatory one + hasher = file_data['hash_hasher'] = self._hash.getHasher() + content_data['stream_object'] = stream.FileStreamObject( + self.host, + client, + file_path, + mode='wb', + uid=self.getProgressId(session, content_name), + size=size, + data_cb=lambda data: hasher.update(data), + ) + else: + # we are sending the file + size = file_data['size'] + # XXX: hash security is not critical here, so we just take the higher mandatory one + hasher = file_data['hash_hasher'] = self._hash.getHasher() + content_data['stream_object'] = stream.FileStreamObject( + self.host, + client, + file_path, + uid=self.getProgressId(session, content_name), + size=size, + data_cb=lambda data: hasher.update(data), + ) finished_d = content_data['finished_d'] = defer.Deferred() args = [client, session, content_name, content_data] finished_d.addCallbacks(self._finishedCb, self._finishedEb, args, None, args) @@ -237,15 +512,21 @@ file_elt = elt.elements((NS_JINGLE_FT, 'file')).next() except StopIteration: raise exceptions.DataError - algo, file_data['hash_given'] = self._hash.parseHashElt(file_elt) + algo, file_data['given_file_hash'] = self._hash.parseHashElt(file_elt) if algo != file_data.get('hash_algo'): - log.warning(u"Hash algorithm used in given hash ({peer_algo}) doesn't correspond to the one we have used ({our_algo})" - .format(peer_algo=algo, our_algo=file_data.get('hash_algo'))) + log.warning(u"Hash algorithm used in given hash ({peer_algo}) doesn't correspond to the one we have used ({our_algo}) [{profile}]" + .format(peer_algo=algo, our_algo=file_data.get('hash_algo'), profile=client.profile)) else: self._receiverTryTerminate(client, session, content_name, content_data) else: raise NotImplementedError + def jingleTerminate(self, client, action, session, content_name, jingle_elt): + if jingle_elt.decline: + # progress is the only way to tell to frontends that session has been declined + progress_id = self.getProgressId(session, content_name) + self.host.bridge.progressError(progress_id, C.PROGRESS_ERROR_DECLINED, client.profile) + def _sendCheckSum(self, client, session, content_name, content_data): """Send the session-info with the hash checksum""" file_data = content_data['application_data']['file_data'] @@ -272,10 +553,10 @@ if not content_data.get('transfer_finished', False): return False file_data = content_data['application_data']['file_data'] - hash_given = file_data.get('hash_given') - if hash_given is None: + given_hash = file_data.get('given_file_hash') + if given_hash is None: if last_try: - log.warning(u"sender didn't sent hash checksum, we can't check the file") + log.warning(u"sender didn't sent hash checksum, we can't check the file [{profile}]".format(profile=client.profile)) self._j.delayedContentTerminate(client, session, content_name) content_data['stream_object'].close() return True @@ -283,7 +564,7 @@ hasher = file_data['hash_hasher'] hash_ = hasher.hexdigest() - if hash_ == hash_given: + if hash_ == given_hash: log.info(u"Hash checked, file was successfully transfered: {}".format(hash_)) progress_metadata = {'hash': hash_, 'hash_algo': file_data['hash_algo'], @@ -295,7 +576,7 @@ progress_metadata=None error = u"Hash mismatch: given={algo}:{given}, calculated={algo}:{our}".format( algo = file_data['hash_algo'], - given = hash_given, + given = given_hash, our = hash_) self._j.delayedContentTerminate(client, session, content_name)