comparison 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
comparison
equal deleted inserted replaced
1639:baac2e120600 1640:d470affbe65c
1 #!/usr/bin/python
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for Jingle File Transfer (XEP-0363)
5 # Copyright (C) 2009, 2010, 2011, 2012, 2013, 2014, 2015 Jérôme Poisson (goffi@goffi.org)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 from sat.core.i18n import _
21 from sat.core.constants import Const as C
22 from sat.core.log import getLogger
23 log = getLogger(__name__)
24 from sat.core import exceptions
25 from wokkel import disco, iwokkel
26 from zope.interface import implements
27 from twisted.words.protocols.jabber import jid
28 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
29 from twisted.internet import reactor
30 from twisted.internet import defer
31 from twisted.internet import ssl
32 from twisted.internet.interfaces import IOpenSSLClientConnectionCreator
33 from twisted.web import client as http_client
34 from twisted.web import http_headers
35 from twisted.web import iweb
36 from twisted.python import failure
37 from collections import namedtuple
38 from zope.interface import implementer
39 from OpenSSL import SSL
40 import os.path
41 import mimetypes
42
43
44 PLUGIN_INFO = {
45 "name": "HTTP File Upload",
46 "import_name": "XEP-0363",
47 "type": "XEP",
48 "protocols": ["XEP-0363"],
49 "dependencies": ["FILE", "UPLOAD"],
50 "main": "XEP_0363",
51 "handler": "yes",
52 "description": _("""Implementation of HTTP File Upload""")
53 }
54
55 NS_HTTP_UPLOAD = 'urn:xmpp:http:upload'
56
57
58 Slot = namedtuple('Slot', ['put', 'get'])
59
60
61 @implementer(IOpenSSLClientConnectionCreator)
62 class NoCheckConnectionCreator(object):
63
64 def __init__(self, hostname, ctx):
65 self._ctx = ctx
66
67 def clientConnectionForTLS(self, tlsProtocol):
68 context = self._ctx
69 connection = SSL.Connection(context, None)
70 connection.set_app_data(tlsProtocol)
71 return connection
72
73
74 @implementer(iweb.IPolicyForHTTPS)
75 class NoCheckContextFactory(ssl.ClientContextFactory):
76 """Context factory which doesn't do TLS certificate check
77
78 /!\\ it's obvisously a security flaw to use this class,
79 and it should be used only wiht explicite agreement from the end used
80 """
81
82 def creatorForNetloc(self, hostname, port):
83 log.warning(u"TLS check disabled for {host} on port {port}".format(host=hostname, port=port))
84 certificateOptions = ssl.CertificateOptions(trustRoot=None)
85 return NoCheckConnectionCreator(hostname, certificateOptions.getContext())
86
87
88 class XEP_0363(object):
89
90 def __init__(self, host):
91 log.info(_("plugin HTTP File Upload initialization"))
92 self.host = host
93 host.bridge.addMethod("fileHTTPUpload", ".plugin", in_sign='sssbs', out_sign='', method=self._fileHTTPUpload)
94 host.bridge.addMethod("fileHTTPUploadGetSlot", ".plugin", in_sign='sisss', out_sign='(ss)', method=self._getSlot, async=True)
95 host.plugins['UPLOAD'].register(u"HTTP Upload", self.getHTTPUploadEntity, self.fileHTTPUpload)
96
97 def getHandler(self, profile):
98 return XEP_0363_handler()
99
100 @defer.inlineCallbacks
101 def getHTTPUploadEntity(self, upload_jid=None, profile=C.PROF_KEY_NONE):
102 """Get HTTP upload capable entity
103
104 upload_jid is checked, then its components
105 @param upload_jid(None, jid.JID): entity to check
106 @return(D(jid.JID)): first HTTP upload capable entity
107 @raise exceptions.NotFound: no entity found
108 """
109 client = self.host.getClient(profile)
110 try:
111 entity = client.http_upload_service
112 except AttributeError:
113 found_entities = yield self.host.findFeaturesSet((NS_HTTP_UPLOAD,), profile=client.profile)
114 try:
115 entity = client.http_upload_service = iter(found_entities).next()
116 except StopIteration:
117 entity = client.http_upload_service = None
118
119 if entity is None:
120 raise failure.Failure(exceptions.NotFound(u'No HTTP upload entity found'))
121
122 defer.returnValue(entity)
123
124 def _fileHTTPUpload(self, filepath, filename='', upload_jid='', ignore_tls_errors=False, profile=C.PROF_KEY_NONE):
125 assert os.path.isabs(filepath) and os.path.isfile(filepath)
126 return self.fileHTTPUpload(filepath, filename or None, jid.JID(upload_jid) if upload_jid else None, {'ignore-tls-errors': ignore_tls_errors}, profile)
127
128 def fileHTTPUpload(self, filepath, filename=None, upload_jid=None, options=None, profile=C.PROF_KEY_NONE):
129 """upload a file through HTTP
130
131 @param filepath(str): absolute path of the file
132 @param filename(None, unicode): name to use for the upload
133 None to use basename of the path
134 @param upload_jid(jid.JID, None): upload capable entity jid,
135 or None to use autodetected, if possible
136 @param options(dict): options where key can be:
137 - ignore_tls_errors(bool): if True, SSL certificate will not be checked
138 @param profile: %(doc_profile)s
139 @return (D(unicode)): progress id
140 """
141 if options is None:
142 options = {}
143 ignore_tls_errors = options.get('ignore-tls-errors', False)
144 client = self.host.getClient(profile)
145 filename = filename or os.path.basename(filepath)
146 size = os.path.getsize(filepath)
147 progress_id_d = defer.Deferred()
148 d = self.getSlot(client, filename, size, upload_jid=upload_jid)
149 d.addCallbacks(self._getSlotCb, self._getSlotEb, (client, progress_id_d, filepath, size, ignore_tls_errors), None, (client, progress_id_d))
150 return progress_id_d
151
152 def _getSlotEb(self, fail, client, progress_id_d):
153 """an error happened while trying to get slot"""
154 log.warning(u"Can't get upload slot: {reason}".format(reason=fail.value))
155 progress_id_d.errback(fail)
156
157 def _getSlotCb(self, slot, client, progress_id_d, path, size, ignore_tls_errors=False):
158 """Called when slot is received, try to do the upload
159
160 @param slot(Slot): slot instance with the get and put urls
161 @param progress_id_d(defer.Deferred): Deferred to call when progress_id is known
162 @param path(str): path to the file to upload
163 @param size(int): size of the file to upload
164 @param ignore_tls_errors(bool): ignore TLS certificate is True
165 @return (tuple
166 """
167 log.debug(u"Got upload slot: {}".format(slot))
168 sat_file = self.host.plugins['FILE'].File(self.host, path, size=size, auto_end_signals=False, profile=client.profile)
169 progress_id_d.callback(sat_file.uid)
170 file_producer = http_client.FileBodyProducer(sat_file)
171 if ignore_tls_errors:
172 agent = http_client.Agent(reactor, NoCheckContextFactory())
173 else:
174 agent = http_client.Agent(reactor)
175 d = agent.request('PUT', slot.put.encode('utf-8'), http_headers.Headers({'User-Agent': [C.APP_NAME.encode('utf-8')]}), file_producer)
176 d.addCallbacks(self._uploadCb, self._uploadEb, (sat_file, slot), None, (sat_file,))
177 return d
178
179 def _uploadCb(self, dummy, sat_file, slot):
180 """Called once file is successfully uploaded
181
182 @param sat_file(SatFile): file used for the upload
183 should be closed, be is needed to send the progressFinished signal
184 @param slot(Slot): put/get urls
185 """
186 log.info(u"HTTP upload finished")
187 sat_file.progressFinished({'url': slot.get})
188
189 def _uploadEb(self, fail, sat_file):
190 """Called on unsuccessful upload
191
192 @param sat_file(SatFile): file used for the upload
193 should be closed, be is needed to send the progressError signal
194 """
195 try:
196 wrapped_fail = fail.value.reasons[0]
197 except (AttributeError, IndexError):
198 sat_file.progressError(unicode(fail))
199 raise fail
200 else:
201 if wrapped_fail.check(SSL.Error):
202 msg = u"TLS validation error, can't connect to HTTPS server"
203 log.warning(msg + ": " + unicode(wrapped_fail.value))
204 sat_file.progressError(msg)
205
206 def _gotSlot(self, iq_elt, client):
207 """Slot have been received
208
209 This method convert the iq_elt result to a Slot instance
210 @param iq_elt(domish.Element): <IQ/> result as specified in XEP-0363
211 """
212 try:
213 slot_elt = iq_elt.elements(NS_HTTP_UPLOAD, 'slot').next()
214 put_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'put').next())
215 get_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'get').next())
216 except StopIteration:
217 raise exceptions.DataError(u"Incorrect stanza received from server")
218 slot = Slot(put=put_url, get=get_url)
219 return slot
220
221 def _getSlot(self, filename, size, content_type, upload_jid, profile_key=C.PROF_KEY_NONE):
222 """Get a upload slot
223
224 This method can be used when uploading is done by the frontend
225 @param filename(unicode): name of the file to upload
226 @param size(int): size of the file (must be non null)
227 @param upload_jid(jid.JID(), None, ''): HTTP upload capable entity
228 @param content_type(unicode, None): MIME type of the content
229 empty string or None to guess automatically
230 """
231 filename.replace('/', '_')
232 client = self.host.getClient(profile_key)
233 return self.getSlot(client, filename, size, content_type or None, upload_jid or None)
234
235 def getSlot(self, client, filename, size, content_type=None, upload_jid=None):
236 """Get a slot (i.e. download/upload links)
237
238 @param filename(unicode): name to use for the upload
239 @param size(int): size of the file to upload (must be >0)
240 @param content_type(None, unicode): MIME type of the content
241 None to autodetect
242 @param upload_jid(jid.JID, None): HTTP upload capable upload_jid
243 or None to use the server component (if any)
244 @param client: %(doc_client)s
245 @return (Slot): the upload (put) and download (get) URLs
246 @raise exceptions.NotFound: no HTTP upload capable upload_jid has been found
247 """
248 assert filename and size
249 if content_type is None:
250 # TODO: manage python magic for file guessing (in a dedicated plugin ?)
251 content_type = mimetypes.guess_type(filename, strict=False)[0]
252
253 if upload_jid is None:
254 try:
255 upload_jid = client.http_upload_service
256 except AttributeError:
257 d = self.getHTTPUploadEntity(profile=client.profile)
258 d.addCallback(lambda found_entity: self.getSlot(client, filename, size, content_type, found_entity))
259 return d
260 else:
261 if upload_jid is None:
262 raise failure.Failure(exceptions.NotFound(u'No HTTP upload entity found'))
263
264 iq_elt = client.IQ('get')
265 iq_elt['to'] = upload_jid.full()
266 request_elt = iq_elt.addElement((NS_HTTP_UPLOAD, 'request'))
267 request_elt.addElement('filename', content=filename)
268 request_elt.addElement('size', content=unicode(size))
269 if content_type is not None:
270 request_elt.addElement('content-type', content=content_type)
271
272 d = iq_elt.send()
273 d.addCallback(self._gotSlot, client)
274
275 return d
276
277
278 class XEP_0363_handler(XMPPHandler):
279 implements(iwokkel.IDisco)
280
281 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
282 return [disco.DiscoFeature(NS_HTTP_UPLOAD)]
283
284 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
285 return []