diff src/plugins/plugin_xep_0363.py @ 1640:d470affbe65c

plugin XEP-0363, upload: File upload (through HTTP upload only for now): - HTTP upload implementation - if the upload link is HTTPS, certificate is checked (can be disabled on demand) - file can be uploaded directly, or a put/get slot can be requested without actual upload. The later is mainly useful for distant frontends like Libervia - upload plugin manage different upload methods, in a similar way as file plugin - download url is sent in progressFinished metadata on successful upload
author Goffi <goffi@goffi.org>
date Sun, 22 Nov 2015 17:33:30 +0100
parents
children d17772b0fe22
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/plugins/plugin_xep_0363.py	Sun Nov 22 17:33:30 2015 +0100
@@ -0,0 +1,285 @@
+#!/usr/bin/python
+# -*- coding: utf-8 -*-
+
+# SAT plugin for Jingle File Transfer (XEP-0363)
+# Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015 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.constants import Const as C
+from sat.core.log import getLogger
+log = getLogger(__name__)
+from sat.core import exceptions
+from wokkel import disco, iwokkel
+from zope.interface import implements
+from twisted.words.protocols.jabber import jid
+from twisted.words.protocols.jabber.xmlstream import XMPPHandler
+from twisted.internet import reactor
+from twisted.internet import defer
+from twisted.internet import ssl
+from twisted.internet.interfaces import IOpenSSLClientConnectionCreator
+from twisted.web import client as http_client
+from twisted.web import http_headers
+from twisted.web import iweb
+from twisted.python import failure
+from collections import namedtuple
+from zope.interface import implementer
+from OpenSSL import SSL
+import os.path
+import mimetypes
+
+
+PLUGIN_INFO = {
+    "name": "HTTP File Upload",
+    "import_name": "XEP-0363",
+    "type": "XEP",
+    "protocols": ["XEP-0363"],
+    "dependencies": ["FILE", "UPLOAD"],
+    "main": "XEP_0363",
+    "handler": "yes",
+    "description": _("""Implementation of HTTP File Upload""")
+}
+
+NS_HTTP_UPLOAD = 'urn:xmpp:http:upload'
+
+
+Slot = namedtuple('Slot', ['put', 'get'])
+
+
+@implementer(IOpenSSLClientConnectionCreator)
+class NoCheckConnectionCreator(object):
+
+    def __init__(self, hostname, ctx):
+        self._ctx = ctx
+
+    def clientConnectionForTLS(self, tlsProtocol):
+        context = self._ctx
+        connection = SSL.Connection(context, None)
+        connection.set_app_data(tlsProtocol)
+        return connection
+
+
+@implementer(iweb.IPolicyForHTTPS)
+class NoCheckContextFactory(ssl.ClientContextFactory):
+    """Context factory which doesn't do TLS certificate check
+
+    /!\\ it's obvisously a security flaw to use this class,
+    and it should be used only wiht explicite agreement from the end used
+    """
+
+    def creatorForNetloc(self, hostname, port):
+        log.warning(u"TLS check disabled for {host} on port {port}".format(host=hostname, port=port))
+        certificateOptions = ssl.CertificateOptions(trustRoot=None)
+        return NoCheckConnectionCreator(hostname, certificateOptions.getContext())
+
+
+class XEP_0363(object):
+
+    def __init__(self, host):
+        log.info(_("plugin HTTP File Upload initialization"))
+        self.host = host
+        host.bridge.addMethod("fileHTTPUpload", ".plugin", in_sign='sssbs', out_sign='', method=self._fileHTTPUpload)
+        host.bridge.addMethod("fileHTTPUploadGetSlot", ".plugin", in_sign='sisss', out_sign='(ss)', method=self._getSlot, async=True)
+        host.plugins['UPLOAD'].register(u"HTTP Upload", self.getHTTPUploadEntity, self.fileHTTPUpload)
+
+    def getHandler(self, profile):
+        return XEP_0363_handler()
+
+    @defer.inlineCallbacks
+    def getHTTPUploadEntity(self, upload_jid=None, profile=C.PROF_KEY_NONE):
+         """Get HTTP upload capable entity
+
+         upload_jid is checked, then its components
+         @param upload_jid(None, jid.JID): entity to check
+         @return(D(jid.JID)): first HTTP upload capable entity
+         @raise exceptions.NotFound: no entity found
+         """
+         client = self.host.getClient(profile)
+         try:
+             entity = client.http_upload_service
+         except AttributeError:
+             found_entities = yield self.host.findFeaturesSet((NS_HTTP_UPLOAD,), profile=client.profile)
+             try:
+                 entity = client.http_upload_service = iter(found_entities).next()
+             except StopIteration:
+                 entity = client.http_upload_service = None
+
+         if entity is None:
+             raise failure.Failure(exceptions.NotFound(u'No HTTP upload entity found'))
+
+         defer.returnValue(entity)
+
+    def _fileHTTPUpload(self, filepath, filename='', upload_jid='', ignore_tls_errors=False, profile=C.PROF_KEY_NONE):
+        assert os.path.isabs(filepath) and os.path.isfile(filepath)
+        return self.fileHTTPUpload(filepath, filename or None, jid.JID(upload_jid) if upload_jid else None, {'ignore-tls-errors': ignore_tls_errors}, profile)
+
+    def fileHTTPUpload(self, filepath, filename=None, upload_jid=None, options=None, profile=C.PROF_KEY_NONE):
+        """upload a file through HTTP
+
+        @param filepath(str): absolute path of the file
+        @param filename(None, unicode): name to use for the upload
+            None to use basename of the path
+        @param upload_jid(jid.JID, None): upload capable entity jid,
+            or None to use autodetected, if possible
+        @param options(dict): options where key can be:
+            - ignore_tls_errors(bool): if True, SSL certificate will not be checked
+        @param profile: %(doc_profile)s
+        @return (D(unicode)): progress id
+        """
+        if options is None:
+            options = {}
+        ignore_tls_errors = options.get('ignore-tls-errors', False)
+        client = self.host.getClient(profile)
+        filename = filename or os.path.basename(filepath)
+        size = os.path.getsize(filepath)
+        progress_id_d = defer.Deferred()
+        d = self.getSlot(client, filename, size, upload_jid=upload_jid)
+        d.addCallbacks(self._getSlotCb, self._getSlotEb, (client, progress_id_d, filepath, size, ignore_tls_errors), None, (client, progress_id_d))
+        return progress_id_d
+
+    def _getSlotEb(self, fail, client, progress_id_d):
+        """an error happened while trying to get slot"""
+        log.warning(u"Can't get upload slot: {reason}".format(reason=fail.value))
+        progress_id_d.errback(fail)
+
+    def _getSlotCb(self, slot, client, progress_id_d, path, size, ignore_tls_errors=False):
+        """Called when slot is received, try to do the upload
+
+        @param slot(Slot): slot instance with the get and put urls
+        @param progress_id_d(defer.Deferred): Deferred to call when progress_id is known
+        @param path(str): path to the file to upload
+        @param size(int): size of the file to upload
+        @param ignore_tls_errors(bool): ignore TLS certificate is True
+        @return (tuple
+        """
+        log.debug(u"Got upload slot: {}".format(slot))
+        sat_file = self.host.plugins['FILE'].File(self.host, path, size=size, auto_end_signals=False, profile=client.profile)
+        progress_id_d.callback(sat_file.uid)
+        file_producer = http_client.FileBodyProducer(sat_file)
+        if ignore_tls_errors:
+            agent = http_client.Agent(reactor, NoCheckContextFactory())
+        else:
+            agent = http_client.Agent(reactor)
+        d = agent.request('PUT', slot.put.encode('utf-8'), http_headers.Headers({'User-Agent': [C.APP_NAME.encode('utf-8')]}), file_producer)
+        d.addCallbacks(self._uploadCb, self._uploadEb, (sat_file, slot), None, (sat_file,))
+        return d
+
+    def _uploadCb(self, dummy, sat_file, slot):
+        """Called once file is successfully uploaded
+
+        @param sat_file(SatFile): file used for the upload
+            should be closed, be is needed to send the progressFinished signal
+        @param slot(Slot): put/get urls
+        """
+        log.info(u"HTTP upload finished")
+        sat_file.progressFinished({'url': slot.get})
+
+    def _uploadEb(self, fail, sat_file):
+        """Called on unsuccessful upload
+
+        @param sat_file(SatFile): file used for the upload
+            should be closed, be is needed to send the progressError signal
+        """
+        try:
+            wrapped_fail = fail.value.reasons[0]
+        except (AttributeError, IndexError):
+            sat_file.progressError(unicode(fail))
+            raise fail
+        else:
+            if wrapped_fail.check(SSL.Error):
+                msg = u"TLS validation error, can't connect to HTTPS server"
+                log.warning(msg + ": " + unicode(wrapped_fail.value))
+                sat_file.progressError(msg)
+
+    def _gotSlot(self, iq_elt, client):
+        """Slot have been received
+
+        This method convert the iq_elt result to a Slot instance
+        @param iq_elt(domish.Element): <IQ/> result as specified in XEP-0363
+        """
+        try:
+            slot_elt = iq_elt.elements(NS_HTTP_UPLOAD, 'slot').next()
+            put_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'put').next())
+            get_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'get').next())
+        except StopIteration:
+            raise exceptions.DataError(u"Incorrect stanza received from server")
+        slot = Slot(put=put_url, get=get_url)
+        return slot
+
+    def _getSlot(self, filename, size, content_type, upload_jid, profile_key=C.PROF_KEY_NONE):
+        """Get a upload slot
+
+        This method can be used when uploading is done by the frontend
+        @param filename(unicode): name of the file to upload
+        @param size(int): size of the file (must be non null)
+        @param upload_jid(jid.JID(), None, ''): HTTP upload capable entity
+        @param content_type(unicode, None): MIME type of the content
+            empty string or None to guess automatically
+        """
+        filename.replace('/', '_')
+        client = self.host.getClient(profile_key)
+        return  self.getSlot(client, filename, size, content_type or None, upload_jid or None)
+
+    def getSlot(self, client, filename, size, content_type=None, upload_jid=None):
+        """Get a slot (i.e. download/upload links)
+
+        @param filename(unicode): name to use for the upload
+        @param size(int): size of the file to upload (must be >0)
+        @param content_type(None, unicode): MIME type of the content
+            None to autodetect
+        @param upload_jid(jid.JID, None): HTTP upload capable upload_jid
+            or None to use the server component (if any)
+        @param client: %(doc_client)s
+        @return (Slot): the upload (put) and download (get) URLs
+        @raise exceptions.NotFound: no HTTP upload capable upload_jid has been found
+        """
+        assert filename and size
+        if content_type is None:
+            # TODO: manage python magic for file guessing (in a dedicated plugin ?)
+            content_type = mimetypes.guess_type(filename, strict=False)[0]
+
+        if upload_jid is None:
+            try:
+                upload_jid = client.http_upload_service
+            except AttributeError:
+                d = self.getHTTPUploadEntity(profile=client.profile)
+                d.addCallback(lambda found_entity: self.getSlot(client, filename, size, content_type, found_entity))
+                return d
+            else:
+                if upload_jid is None:
+                    raise failure.Failure(exceptions.NotFound(u'No HTTP upload entity found'))
+
+        iq_elt = client.IQ('get')
+        iq_elt['to'] = upload_jid.full()
+        request_elt = iq_elt.addElement((NS_HTTP_UPLOAD, 'request'))
+        request_elt.addElement('filename', content=filename)
+        request_elt.addElement('size', content=unicode(size))
+        if content_type is not None:
+            request_elt.addElement('content-type', content=content_type)
+
+        d = iq_elt.send()
+        d.addCallback(self._gotSlot, client)
+
+        return d
+
+
+class XEP_0363_handler(XMPPHandler):
+    implements(iwokkel.IDisco)
+
+    def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
+        return [disco.DiscoFeature(NS_HTTP_UPLOAD)]
+
+    def getDiscoItems(self, requestor, target, nodeIdentifier=''):
+        return []