comparison sat/plugins/plugin_xep_0363.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_0363.py@7ad5f2c4e34a
children 56f94936df1e
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for HTTP File Upload (XEP-0363)
5 # Copyright (C) 2009-2018 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 C.PI_NAME: "HTTP File Upload",
46 C.PI_IMPORT_NAME: "XEP-0363",
47 C.PI_TYPE: "XEP",
48 C.PI_PROTOCOLS: ["XEP-0363"],
49 C.PI_DEPENDENCIES: ["FILE", "UPLOAD"],
50 C.PI_MAIN: "XEP_0363",
51 C.PI_HANDLER: "yes",
52 C.PI_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, client):
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(client, (NS_HTTP_UPLOAD,))
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 progress_id_d, dummy = self.fileHTTPUpload(filepath, filename or None, jid.JID(upload_jid) if upload_jid else None, {'ignore_tls_errors': ignore_tls_errors}, profile)
127 return progress_id_d
128
129 def fileHTTPUpload(self, filepath, filename=None, upload_jid=None, options=None, profile=C.PROF_KEY_NONE):
130 """upload a file through HTTP
131
132 @param filepath(str): absolute path of the file
133 @param filename(None, unicode): name to use for the upload
134 None to use basename of the path
135 @param upload_jid(jid.JID, None): upload capable entity jid,
136 or None to use autodetected, if possible
137 @param options(dict): options where key can be:
138 - ignore_tls_errors(bool): if True, SSL certificate will not be checked
139 @param profile: %(doc_profile)s
140 @return (D(tuple[D(unicode), D(unicode)])): progress id and Deferred which fire download URL
141 """
142 if options is None:
143 options = {}
144 ignore_tls_errors = options.get('ignore_tls_errors', False)
145 client = self.host.getClient(profile)
146 filename = filename or os.path.basename(filepath)
147 size = os.path.getsize(filepath)
148 progress_id_d = defer.Deferred()
149 download_d = defer.Deferred()
150 d = self.getSlot(client, filename, size, upload_jid=upload_jid)
151 d.addCallbacks(self._getSlotCb, self._getSlotEb, (client, progress_id_d, download_d, filepath, size, ignore_tls_errors), None, (client, progress_id_d, download_d))
152 return progress_id_d, download_d
153
154 def _getSlotEb(self, fail, client, progress_id_d, download_d):
155 """an error happened while trying to get slot"""
156 log.warning(u"Can't get upload slot: {reason}".format(reason=fail.value))
157 progress_id_d.errback(fail)
158 download_d.errback(fail)
159
160 def _getSlotCb(self, slot, client, progress_id_d, download_d, path, size, ignore_tls_errors=False):
161 """Called when slot is received, try to do the upload
162
163 @param slot(Slot): slot instance with the get and put urls
164 @param progress_id_d(defer.Deferred): Deferred to call when progress_id is known
165 @param progress_id_d(defer.Deferred): Deferred to call with URL when upload is done
166 @param path(str): path to the file to upload
167 @param size(int): size of the file to upload
168 @param ignore_tls_errors(bool): ignore TLS certificate is True
169 @return (tuple
170 """
171 log.debug(u"Got upload slot: {}".format(slot))
172 sat_file = self.host.plugins['FILE'].File(self.host, client, path, size=size, auto_end_signals=False)
173 progress_id_d.callback(sat_file.uid)
174 file_producer = http_client.FileBodyProducer(sat_file)
175 if ignore_tls_errors:
176 agent = http_client.Agent(reactor, NoCheckContextFactory())
177 else:
178 agent = http_client.Agent(reactor)
179 d = agent.request('PUT', slot.put.encode('utf-8'), http_headers.Headers({'User-Agent': [C.APP_NAME.encode('utf-8')]}), file_producer)
180 d.addCallbacks(self._uploadCb, self._uploadEb, (sat_file, slot, download_d), None, (sat_file, download_d))
181 return d
182
183 def _uploadCb(self, dummy, sat_file, slot, download_d):
184 """Called once file is successfully uploaded
185
186 @param sat_file(SatFile): file used for the upload
187 should be closed, be is needed to send the progressFinished signal
188 @param slot(Slot): put/get urls
189 """
190 log.info(u"HTTP upload finished")
191 sat_file.progressFinished({'url': slot.get})
192 download_d.callback(slot.get)
193
194 def _uploadEb(self, fail, sat_file, download_d):
195 """Called on unsuccessful upload
196
197 @param sat_file(SatFile): file used for the upload
198 should be closed, be is needed to send the progressError signal
199 """
200 download_d.errback(fail)
201 try:
202 wrapped_fail = fail.value.reasons[0]
203 except (AttributeError, IndexError):
204 sat_file.progressError(unicode(fail))
205 raise fail
206 else:
207 if wrapped_fail.check(SSL.Error):
208 msg = u"TLS validation error, can't connect to HTTPS server"
209 log.warning(msg + ": " + unicode(wrapped_fail.value))
210 sat_file.progressError(msg)
211
212 def _gotSlot(self, iq_elt, client):
213 """Slot have been received
214
215 This method convert the iq_elt result to a Slot instance
216 @param iq_elt(domish.Element): <IQ/> result as specified in XEP-0363
217 """
218 try:
219 slot_elt = iq_elt.elements(NS_HTTP_UPLOAD, 'slot').next()
220 put_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'put').next())
221 get_url = unicode(slot_elt.elements(NS_HTTP_UPLOAD, 'get').next())
222 except StopIteration:
223 raise exceptions.DataError(u"Incorrect stanza received from server")
224 slot = Slot(put=put_url, get=get_url)
225 return slot
226
227 def _getSlot(self, filename, size, content_type, upload_jid, profile_key=C.PROF_KEY_NONE):
228 """Get a upload slot
229
230 This method can be used when uploading is done by the frontend
231 @param filename(unicode): name of the file to upload
232 @param size(int): size of the file (must be non null)
233 @param upload_jid(jid.JID(), None, ''): HTTP upload capable entity
234 @param content_type(unicode, None): MIME type of the content
235 empty string or None to guess automatically
236 """
237 filename = filename.replace('/', '_')
238 client = self.host.getClient(profile_key)
239 return self.getSlot(client, filename, size, content_type or None, upload_jid or None)
240
241 def getSlot(self, client, filename, size, content_type=None, upload_jid=None):
242 """Get a slot (i.e. download/upload links)
243
244 @param filename(unicode): name to use for the upload
245 @param size(int): size of the file to upload (must be >0)
246 @param content_type(None, unicode): MIME type of the content
247 None to autodetect
248 @param upload_jid(jid.JID, None): HTTP upload capable upload_jid
249 or None to use the server component (if any)
250 @param client: %(doc_client)s
251 @return (Slot): the upload (put) and download (get) URLs
252 @raise exceptions.NotFound: no HTTP upload capable upload_jid has been found
253 """
254 assert filename and size
255 if content_type is None:
256 # TODO: manage python magic for file guessing (in a dedicated plugin ?)
257 content_type = mimetypes.guess_type(filename, strict=False)[0]
258
259 if upload_jid is None:
260 try:
261 upload_jid = client.http_upload_service
262 except AttributeError:
263 d = self.getHTTPUploadEntity(profile=client.profile)
264 d.addCallback(lambda found_entity: self.getSlot(client, filename, size, content_type, found_entity))
265 return d
266 else:
267 if upload_jid is None:
268 raise failure.Failure(exceptions.NotFound(u'No HTTP upload entity found'))
269
270 iq_elt = client.IQ('get')
271 iq_elt['to'] = upload_jid.full()
272 request_elt = iq_elt.addElement((NS_HTTP_UPLOAD, 'request'))
273 request_elt.addElement('filename', content=filename)
274 request_elt.addElement('size', content=unicode(size))
275 if content_type is not None:
276 request_elt.addElement('content-type', content=content_type)
277
278 d = iq_elt.send()
279 d.addCallback(self._gotSlot, client)
280
281 return d
282
283
284 class XEP_0363_handler(XMPPHandler):
285 implements(iwokkel.IDisco)
286
287 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
288 return [disco.DiscoFeature(NS_HTTP_UPLOAD)]
289
290 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
291 return []