Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0234.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_xep_0234.py@2ced30f6d5de |
children | bc60875cb3b8 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # SàT plugin for Jingle File Transfer (XEP-0234) | |
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) | |
5 | |
6 # This program is free software: you can redistribute it and/or modify | |
7 # it under the terms of the GNU Affero General Public License as published by | |
8 # the Free Software Foundation, either version 3 of the License, or | |
9 # (at your option) any later version. | |
10 | |
11 # This program is distributed in the hope that it will be useful, | |
12 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
13 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
14 # GNU Affero General Public License for more details. | |
15 | |
16 # You should have received a copy of the GNU Affero General Public License | |
17 # along with this program. If not, see <http://www.gnu.org/licenses/>. | |
18 | |
19 from collections import namedtuple | |
20 import mimetypes | |
21 import os.path | |
22 | |
23 from twisted.internet import defer | |
24 from twisted.internet import reactor | |
25 from twisted.internet import error as internet_error | |
26 from twisted.python import failure | |
27 from twisted.words.protocols.jabber import jid | |
28 from twisted.words.protocols.jabber.xmlstream import XMPPHandler | |
29 from twisted.words.xish import domish | |
30 from wokkel import disco, iwokkel | |
31 from zope.interface import implementer | |
32 | |
33 from libervia.backend.core import exceptions | |
34 from libervia.backend.core.constants import Const as C | |
35 from libervia.backend.core.i18n import D_, _ | |
36 from libervia.backend.core.log import getLogger | |
37 from libervia.backend.tools import utils | |
38 from libervia.backend.tools import stream | |
39 from libervia.backend.tools.common import date_utils | |
40 from libervia.backend.tools.common import regex | |
41 | |
42 | |
43 log = getLogger(__name__) | |
44 | |
45 NS_JINGLE_FT = "urn:xmpp:jingle:apps:file-transfer:5" | |
46 | |
47 PLUGIN_INFO = { | |
48 C.PI_NAME: "Jingle File Transfer", | |
49 C.PI_IMPORT_NAME: "XEP-0234", | |
50 C.PI_TYPE: "XEP", | |
51 C.PI_MODES: C.PLUG_MODE_BOTH, | |
52 C.PI_PROTOCOLS: ["XEP-0234"], | |
53 C.PI_DEPENDENCIES: ["XEP-0166", "XEP-0300", "FILE"], | |
54 C.PI_MAIN: "XEP_0234", | |
55 C.PI_HANDLER: "yes", | |
56 C.PI_DESCRIPTION: _("""Implementation of Jingle File Transfer"""), | |
57 } | |
58 | |
59 EXTRA_ALLOWED = {"path", "namespace", "file_desc", "file_hash", "hash_algo"} | |
60 Range = namedtuple("Range", ("offset", "length")) | |
61 | |
62 | |
63 class XEP_0234: | |
64 # TODO: assure everything is closed when file is sent or session terminate is received | |
65 # TODO: call self._f.unregister when unloading order will be managing (i.e. when | |
66 # dependencies will be unloaded at the end) | |
67 Range = Range # we copy the class here, so it can be used by other plugins | |
68 name = PLUGIN_INFO[C.PI_NAME] | |
69 human_name = D_("file transfer") | |
70 | |
71 def __init__(self, host): | |
72 log.info(_("plugin Jingle File Transfer initialization")) | |
73 self.host = host | |
74 host.register_namespace("jingle-ft", NS_JINGLE_FT) | |
75 self._j = host.plugins["XEP-0166"] # shortcut to access jingle | |
76 self._j.register_application(NS_JINGLE_FT, self) | |
77 self._f = host.plugins["FILE"] | |
78 self._f.register(self, priority=10000) | |
79 self._hash = self.host.plugins["XEP-0300"] | |
80 host.bridge.add_method( | |
81 "file_jingle_send", | |
82 ".plugin", | |
83 in_sign="ssssa{ss}s", | |
84 out_sign="", | |
85 method=self._file_send, | |
86 async_=True, | |
87 ) | |
88 host.bridge.add_method( | |
89 "file_jingle_request", | |
90 ".plugin", | |
91 in_sign="sssssa{ss}s", | |
92 out_sign="s", | |
93 method=self._file_jingle_request, | |
94 async_=True, | |
95 ) | |
96 | |
97 def get_handler(self, client): | |
98 return XEP_0234_handler() | |
99 | |
100 def get_progress_id(self, session, content_name): | |
101 """Return a unique progress ID | |
102 | |
103 @param session(dict): jingle session | |
104 @param content_name(unicode): name of the content | |
105 @return (unicode): unique progress id | |
106 """ | |
107 return "{}_{}".format(session["id"], content_name) | |
108 | |
109 async def can_handle_file_send(self, client, peer_jid, filepath): | |
110 if peer_jid.resource: | |
111 return await self.host.hasFeature(client, NS_JINGLE_FT, peer_jid) | |
112 else: | |
113 # if we have a bare jid, Jingle Message Initiation will be tried | |
114 return True | |
115 | |
116 # generic methods | |
117 | |
118 def build_file_element( | |
119 self, client, name=None, file_hash=None, hash_algo=None, size=None, | |
120 mime_type=None, desc=None, modified=None, transfer_range=None, path=None, | |
121 namespace=None, file_elt=None, **kwargs): | |
122 """Generate a <file> element with available metadata | |
123 | |
124 @param file_hash(unicode, None): hash of the file | |
125 empty string to set <hash-used/> element | |
126 @param hash_algo(unicode, None): hash algorithm used | |
127 if file_hash is None and hash_algo is set, a <hash-used/> element will be | |
128 generated | |
129 @param transfer_range(Range, None): where transfer must start/stop | |
130 @param modified(int, unicode, None): date of last modification | |
131 0 to use current date | |
132 int to use an unix timestamp | |
133 else must be an unicode string which will be used as it (it must be an XMPP | |
134 time) | |
135 @param file_elt(domish.Element, None): element to use | |
136 None to create a new one | |
137 @param **kwargs: data for plugin extension (ignored by default) | |
138 @return (domish.Element): generated element | |
139 @trigger XEP-0234_buildFileElement(file_elt, extra_args): can be used to extend | |
140 elements to add | |
141 """ | |
142 if file_elt is None: | |
143 file_elt = domish.Element((NS_JINGLE_FT, "file")) | |
144 for name, value in ( | |
145 ("name", name), | |
146 ("size", size), | |
147 ("media-type", mime_type), | |
148 ("desc", desc), | |
149 ("path", path), | |
150 ("namespace", namespace), | |
151 ): | |
152 if value is not None: | |
153 file_elt.addElement(name, content=str(value)) | |
154 | |
155 if modified is not None: | |
156 if isinstance(modified, int): | |
157 file_elt.addElement("date", utils.xmpp_date(modified or None)) | |
158 else: | |
159 file_elt.addElement("date", modified) | |
160 elif "created" in kwargs: | |
161 file_elt.addElement("date", utils.xmpp_date(kwargs.pop("created"))) | |
162 | |
163 range_elt = file_elt.addElement("range") | |
164 if transfer_range is not None: | |
165 if transfer_range.offset is not None: | |
166 range_elt["offset"] = transfer_range.offset | |
167 if transfer_range.length is not None: | |
168 range_elt["length"] = transfer_range.length | |
169 if file_hash is not None: | |
170 if not file_hash: | |
171 file_elt.addChild(self._hash.build_hash_used_elt()) | |
172 else: | |
173 file_elt.addChild(self._hash.build_hash_elt(file_hash, hash_algo)) | |
174 elif hash_algo is not None: | |
175 file_elt.addChild(self._hash.build_hash_used_elt(hash_algo)) | |
176 self.host.trigger.point( | |
177 "XEP-0234_buildFileElement", client, file_elt, extra_args=kwargs) | |
178 if kwargs: | |
179 for kw in kwargs: | |
180 log.debug("ignored keyword: {}".format(kw)) | |
181 return file_elt | |
182 | |
183 def build_file_element_from_dict(self, client, file_data, **kwargs): | |
184 """like build_file_element but get values from a file_data dict | |
185 | |
186 @param file_data(dict): metadata to use | |
187 @param **kwargs: data to override | |
188 """ | |
189 if kwargs: | |
190 file_data = file_data.copy() | |
191 file_data.update(kwargs) | |
192 try: | |
193 file_data["mime_type"] = ( | |
194 f'{file_data.pop("media_type")}/{file_data.pop("media_subtype")}' | |
195 ) | |
196 except KeyError: | |
197 pass | |
198 return self.build_file_element(client, **file_data) | |
199 | |
200 async def parse_file_element( | |
201 self, client, file_elt, file_data=None, given=False, parent_elt=None, | |
202 keep_empty_range=False): | |
203 """Parse a <file> element and file dictionary accordingly | |
204 | |
205 @param file_data(dict, None): dict where the data will be set | |
206 following keys will be set (and overwritten if they already exist): | |
207 name, file_hash, hash_algo, size, mime_type, desc, path, namespace, range | |
208 if None, a new dict is created | |
209 @param given(bool): if True, prefix hash key with "given_" | |
210 @param parent_elt(domish.Element, None): parent of the file element | |
211 if set, file_elt must not be set | |
212 @param keep_empty_range(bool): if True, keep empty range (i.e. range when offset | |
213 and length are None). | |
214 Empty range is useful to know if a peer_jid can handle range | |
215 @return (dict): file_data | |
216 @trigger XEP-0234_parseFileElement(file_elt, file_data): can be used to parse new | |
217 elements | |
218 @raise exceptions.NotFound: there is not <file> element in parent_elt | |
219 @raise exceptions.DataError: if file_elt uri is not NS_JINGLE_FT | |
220 """ | |
221 if parent_elt is not None: | |
222 if file_elt is not None: | |
223 raise exceptions.InternalError( | |
224 "file_elt must be None if parent_elt is set" | |
225 ) | |
226 try: | |
227 file_elt = next(parent_elt.elements(NS_JINGLE_FT, "file")) | |
228 except StopIteration: | |
229 raise exceptions.NotFound() | |
230 else: | |
231 if not file_elt or file_elt.uri != NS_JINGLE_FT: | |
232 raise exceptions.DataError( | |
233 "invalid <file> element: {stanza}".format(stanza=file_elt.toXml()) | |
234 ) | |
235 | |
236 if file_data is None: | |
237 file_data = {} | |
238 | |
239 for name in ("name", "desc", "path", "namespace"): | |
240 try: | |
241 file_data[name] = str(next(file_elt.elements(NS_JINGLE_FT, name))) | |
242 except StopIteration: | |
243 pass | |
244 | |
245 name = file_data.get("name") | |
246 if name == "..": | |
247 # we don't want to go to parent dir when joining to a path | |
248 name = "--" | |
249 file_data["name"] = name | |
250 elif name is not None and ("/" in name or "\\" in name): | |
251 file_data["name"] = regex.path_escape(name) | |
252 | |
253 try: | |
254 file_data["mime_type"] = str( | |
255 next(file_elt.elements(NS_JINGLE_FT, "media-type")) | |
256 ) | |
257 except StopIteration: | |
258 pass | |
259 | |
260 try: | |
261 file_data["size"] = int( | |
262 str(next(file_elt.elements(NS_JINGLE_FT, "size"))) | |
263 ) | |
264 except StopIteration: | |
265 pass | |
266 | |
267 try: | |
268 file_data["modified"] = date_utils.date_parse( | |
269 next(file_elt.elements(NS_JINGLE_FT, "date")) | |
270 ) | |
271 except StopIteration: | |
272 pass | |
273 | |
274 try: | |
275 range_elt = next(file_elt.elements(NS_JINGLE_FT, "range")) | |
276 except StopIteration: | |
277 pass | |
278 else: | |
279 offset = range_elt.getAttribute("offset") | |
280 length = range_elt.getAttribute("length") | |
281 if offset or length or keep_empty_range: | |
282 file_data["transfer_range"] = Range(offset=offset, length=length) | |
283 | |
284 prefix = "given_" if given else "" | |
285 hash_algo_key, hash_key = "hash_algo", prefix + "file_hash" | |
286 try: | |
287 file_data[hash_algo_key], file_data[hash_key] = self._hash.parse_hash_elt( | |
288 file_elt | |
289 ) | |
290 except exceptions.NotFound: | |
291 pass | |
292 | |
293 self.host.trigger.point("XEP-0234_parseFileElement", client, file_elt, file_data) | |
294 | |
295 return file_data | |
296 | |
297 # bridge methods | |
298 | |
299 def _file_send( | |
300 self, | |
301 peer_jid, | |
302 filepath, | |
303 name="", | |
304 file_desc="", | |
305 extra=None, | |
306 profile=C.PROF_KEY_NONE, | |
307 ): | |
308 client = self.host.get_client(profile) | |
309 return defer.ensureDeferred(self.file_send( | |
310 client, | |
311 jid.JID(peer_jid), | |
312 filepath, | |
313 name or None, | |
314 file_desc or None, | |
315 extra or None, | |
316 )) | |
317 | |
318 async def file_send( | |
319 self, client, peer_jid, filepath, name, file_desc=None, extra=None | |
320 ): | |
321 """Send a file using jingle file transfer | |
322 | |
323 @param peer_jid(jid.JID): destinee jid | |
324 @param filepath(str): absolute path of the file | |
325 @param name(unicode, None): name of the file | |
326 @param file_desc(unicode, None): description of the file | |
327 @return (D(unicode)): progress id | |
328 """ | |
329 progress_id_d = defer.Deferred() | |
330 if extra is None: | |
331 extra = {} | |
332 if file_desc is not None: | |
333 extra["file_desc"] = file_desc | |
334 encrypted = extra.pop("encrypted", False) | |
335 await self._j.initiate( | |
336 client, | |
337 peer_jid, | |
338 [ | |
339 { | |
340 "app_ns": NS_JINGLE_FT, | |
341 "senders": self._j.ROLE_INITIATOR, | |
342 "app_kwargs": { | |
343 "filepath": filepath, | |
344 "name": name, | |
345 "extra": extra, | |
346 "progress_id_d": progress_id_d, | |
347 }, | |
348 } | |
349 ], | |
350 encrypted = encrypted | |
351 ) | |
352 return await progress_id_d | |
353 | |
354 def _file_jingle_request( | |
355 self, peer_jid, filepath, name="", file_hash="", hash_algo="", extra=None, | |
356 profile=C.PROF_KEY_NONE): | |
357 client = self.host.get_client(profile) | |
358 return defer.ensureDeferred(self.file_jingle_request( | |
359 client, | |
360 jid.JID(peer_jid), | |
361 filepath, | |
362 name or None, | |
363 file_hash or None, | |
364 hash_algo or None, | |
365 extra or None, | |
366 )) | |
367 | |
368 async def file_jingle_request( | |
369 self, client, peer_jid, filepath, name=None, file_hash=None, hash_algo=None, | |
370 extra=None): | |
371 """Request a file using jingle file transfer | |
372 | |
373 @param peer_jid(jid.JID): destinee jid | |
374 @param filepath(str): absolute path where the file will be downloaded | |
375 @param name(unicode, None): name of the file | |
376 @param file_hash(unicode, None): hash of the file | |
377 @return (D(unicode)): progress id | |
378 """ | |
379 progress_id_d = defer.Deferred() | |
380 if extra is None: | |
381 extra = {} | |
382 if file_hash is not None: | |
383 if hash_algo is None: | |
384 raise ValueError(_("hash_algo must be set if file_hash is set")) | |
385 extra["file_hash"] = file_hash | |
386 extra["hash_algo"] = hash_algo | |
387 else: | |
388 if hash_algo is not None: | |
389 raise ValueError(_("file_hash must be set if hash_algo is set")) | |
390 await self._j.initiate( | |
391 client, | |
392 peer_jid, | |
393 [ | |
394 { | |
395 "app_ns": NS_JINGLE_FT, | |
396 "senders": self._j.ROLE_RESPONDER, | |
397 "app_kwargs": { | |
398 "filepath": filepath, | |
399 "name": name, | |
400 "extra": extra, | |
401 "progress_id_d": progress_id_d, | |
402 }, | |
403 } | |
404 ], | |
405 ) | |
406 return await progress_id_d | |
407 | |
408 # jingle callbacks | |
409 | |
410 def jingle_description_elt( | |
411 self, client, session, content_name, filepath, name, extra, progress_id_d | |
412 ): | |
413 return domish.Element((NS_JINGLE_FT, "description")) | |
414 | |
415 def jingle_session_init( | |
416 self, client, session, content_name, filepath, name, extra, progress_id_d | |
417 ): | |
418 if extra is None: | |
419 extra = {} | |
420 else: | |
421 if not EXTRA_ALLOWED.issuperset(extra): | |
422 raise ValueError( | |
423 _("only the following keys are allowed in extra: {keys}").format( | |
424 keys=", ".join(EXTRA_ALLOWED) | |
425 ) | |
426 ) | |
427 progress_id_d.callback(self.get_progress_id(session, content_name)) | |
428 content_data = session["contents"][content_name] | |
429 application_data = content_data["application_data"] | |
430 assert "file_path" not in application_data | |
431 application_data["file_path"] = filepath | |
432 file_data = application_data["file_data"] = {} | |
433 desc_elt = self.jingle_description_elt( | |
434 client, session, content_name, filepath, name, extra, progress_id_d) | |
435 file_elt = desc_elt.addElement("file") | |
436 | |
437 if content_data["senders"] == self._j.ROLE_INITIATOR: | |
438 # we send a file | |
439 if name is None: | |
440 name = os.path.basename(filepath) | |
441 file_data["date"] = utils.xmpp_date() | |
442 file_data["desc"] = extra.pop("file_desc", "") | |
443 file_data["name"] = name | |
444 mime_type = mimetypes.guess_type(name, strict=False)[0] | |
445 if mime_type is not None: | |
446 file_data["mime_type"] = mime_type | |
447 file_data["size"] = os.path.getsize(filepath) | |
448 if "namespace" in extra: | |
449 file_data["namespace"] = extra["namespace"] | |
450 if "path" in extra: | |
451 file_data["path"] = extra["path"] | |
452 self.build_file_element_from_dict( | |
453 client, file_data, file_elt=file_elt, file_hash="") | |
454 else: | |
455 # we request a file | |
456 file_hash = extra.pop("file_hash", "") | |
457 if not name and not file_hash: | |
458 raise ValueError(_("you need to provide at least name or file hash")) | |
459 if name: | |
460 file_data["name"] = name | |
461 if file_hash: | |
462 file_data["file_hash"] = file_hash | |
463 file_data["hash_algo"] = extra["hash_algo"] | |
464 else: | |
465 file_data["hash_algo"] = self._hash.get_default_algo() | |
466 if "namespace" in extra: | |
467 file_data["namespace"] = extra["namespace"] | |
468 if "path" in extra: | |
469 file_data["path"] = extra["path"] | |
470 self.build_file_element_from_dict(client, file_data, file_elt=file_elt) | |
471 | |
472 return desc_elt | |
473 | |
474 async def jingle_request_confirmation( | |
475 self, client, action, session, content_name, desc_elt | |
476 ): | |
477 """This method request confirmation for a jingle session""" | |
478 content_data = session["contents"][content_name] | |
479 senders = content_data["senders"] | |
480 if senders not in (self._j.ROLE_INITIATOR, self._j.ROLE_RESPONDER): | |
481 log.warning("Bad sender, assuming initiator") | |
482 senders = content_data["senders"] = self._j.ROLE_INITIATOR | |
483 # first we grab file informations | |
484 try: | |
485 file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file")) | |
486 except StopIteration: | |
487 raise failure.Failure(exceptions.DataError) | |
488 file_data = {"progress_id": self.get_progress_id(session, content_name)} | |
489 | |
490 if senders == self._j.ROLE_RESPONDER: | |
491 # we send the file | |
492 return await self._file_sending_request_conf( | |
493 client, session, content_data, content_name, file_data, file_elt | |
494 ) | |
495 else: | |
496 # we receive the file | |
497 return await self._file_receiving_request_conf( | |
498 client, session, content_data, content_name, file_data, file_elt | |
499 ) | |
500 | |
501 async def _file_sending_request_conf( | |
502 self, client, session, content_data, content_name, file_data, file_elt | |
503 ): | |
504 """parse file_elt, and handle file retrieving/permission checking""" | |
505 await self.parse_file_element(client, file_elt, file_data) | |
506 content_data["application_data"]["file_data"] = file_data | |
507 finished_d = content_data["finished_d"] = defer.Deferred() | |
508 | |
509 # confirmed_d is a deferred returning confimed value (only used if cont is False) | |
510 cont, confirmed_d = self.host.trigger.return_point( | |
511 "XEP-0234_fileSendingRequest", | |
512 client, | |
513 session, | |
514 content_data, | |
515 content_name, | |
516 file_data, | |
517 file_elt, | |
518 ) | |
519 if not cont: | |
520 confirmed = await confirmed_d | |
521 if confirmed: | |
522 args = [client, session, content_name, content_data] | |
523 finished_d.addCallbacks( | |
524 self._finished_cb, self._finished_eb, args, None, args | |
525 ) | |
526 return confirmed | |
527 | |
528 log.warning(_("File continue is not implemented yet")) | |
529 return False | |
530 | |
531 async def _file_receiving_request_conf( | |
532 self, client, session, content_data, content_name, file_data, file_elt | |
533 ): | |
534 """parse file_elt, and handle user permission/file opening""" | |
535 await self.parse_file_element(client, file_elt, file_data, given=True) | |
536 try: | |
537 hash_algo, file_data["given_file_hash"] = self._hash.parse_hash_elt(file_elt) | |
538 except exceptions.NotFound: | |
539 try: | |
540 hash_algo = self._hash.parse_hash_used_elt(file_elt) | |
541 except exceptions.NotFound: | |
542 raise failure.Failure(exceptions.DataError) | |
543 | |
544 if hash_algo is not None: | |
545 file_data["hash_algo"] = hash_algo | |
546 file_data["hash_hasher"] = hasher = self._hash.get_hasher(hash_algo) | |
547 file_data["data_cb"] = lambda data: hasher.update(data) | |
548 | |
549 try: | |
550 file_data["size"] = int(file_data["size"]) | |
551 except ValueError: | |
552 raise failure.Failure(exceptions.DataError) | |
553 | |
554 name = file_data["name"] | |
555 if "/" in name or "\\" in name: | |
556 log.warning( | |
557 "File name contain path characters, we replace them: {}".format(name) | |
558 ) | |
559 file_data["name"] = name.replace("/", "_").replace("\\", "_") | |
560 | |
561 content_data["application_data"]["file_data"] = file_data | |
562 | |
563 # now we actualy request permission to user | |
564 | |
565 # deferred to track end of transfer | |
566 finished_d = content_data["finished_d"] = defer.Deferred() | |
567 confirmed = await self._f.get_dest_dir( | |
568 client, session["peer_jid"], content_data, file_data, stream_object=True | |
569 ) | |
570 if confirmed: | |
571 await self.host.trigger.async_point( | |
572 "XEP-0234_file_receiving_request_conf", | |
573 client, session, content_data, file_elt | |
574 ) | |
575 args = [client, session, content_name, content_data] | |
576 finished_d.addCallbacks( | |
577 self._finished_cb, self._finished_eb, args, None, args | |
578 ) | |
579 return confirmed | |
580 | |
581 async def jingle_handler(self, client, action, session, content_name, desc_elt): | |
582 content_data = session["contents"][content_name] | |
583 application_data = content_data["application_data"] | |
584 if action in (self._j.A_ACCEPTED_ACK,): | |
585 pass | |
586 elif action == self._j.A_SESSION_INITIATE: | |
587 file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file")) | |
588 try: | |
589 next(file_elt.elements(NS_JINGLE_FT, "range")) | |
590 except StopIteration: | |
591 # initiator doesn't manage <range>, but we do so we advertise it | |
592 # FIXME: to be checked | |
593 log.debug("adding <range> element") | |
594 file_elt.addElement("range") | |
595 elif action == self._j.A_SESSION_ACCEPT: | |
596 assert not "stream_object" in content_data | |
597 file_data = application_data["file_data"] | |
598 file_path = application_data["file_path"] | |
599 senders = content_data["senders"] | |
600 if senders != session["role"]: | |
601 # we are receiving the file | |
602 try: | |
603 # did the responder specified the size of the file? | |
604 file_elt = next(desc_elt.elements(NS_JINGLE_FT, "file")) | |
605 size_elt = next(file_elt.elements(NS_JINGLE_FT, "size")) | |
606 size = int(str(size_elt)) | |
607 except (StopIteration, ValueError): | |
608 size = None | |
609 # XXX: hash security is not critical here, so we just take the higher | |
610 # mandatory one | |
611 hasher = file_data["hash_hasher"] = self._hash.get_hasher() | |
612 progress_id = self.get_progress_id(session, content_name) | |
613 try: | |
614 content_data["stream_object"] = stream.FileStreamObject( | |
615 self.host, | |
616 client, | |
617 file_path, | |
618 mode="wb", | |
619 uid=progress_id, | |
620 size=size, | |
621 data_cb=lambda data: hasher.update(data), | |
622 ) | |
623 except Exception as e: | |
624 self.host.bridge.progress_error( | |
625 progress_id, C.PROGRESS_ERROR_FAILED, client.profile | |
626 ) | |
627 await self._j.terminate( | |
628 client, self._j.REASON_FAILED_APPLICATION, session) | |
629 raise e | |
630 else: | |
631 # we are sending the file | |
632 size = file_data["size"] | |
633 # XXX: hash security is not critical here, so we just take the higher | |
634 # mandatory one | |
635 hasher = file_data["hash_hasher"] = self._hash.get_hasher() | |
636 content_data["stream_object"] = stream.FileStreamObject( | |
637 self.host, | |
638 client, | |
639 file_path, | |
640 uid=self.get_progress_id(session, content_name), | |
641 size=size, | |
642 data_cb=lambda data: hasher.update(data), | |
643 ) | |
644 finished_d = content_data["finished_d"] = defer.Deferred() | |
645 args = [client, session, content_name, content_data] | |
646 finished_d.addCallbacks(self._finished_cb, self._finished_eb, args, None, args) | |
647 await self.host.trigger.async_point( | |
648 "XEP-0234_jingle_handler", | |
649 client, session, content_data, desc_elt | |
650 ) | |
651 else: | |
652 log.warning("FIXME: unmanaged action {}".format(action)) | |
653 return desc_elt | |
654 | |
655 def jingle_session_info(self, client, action, session, content_name, jingle_elt): | |
656 """Called on session-info action | |
657 | |
658 manage checksum, and ignore <received/> element | |
659 """ | |
660 # TODO: manage <received/> element | |
661 content_data = session["contents"][content_name] | |
662 elts = [elt for elt in jingle_elt.elements() if elt.uri == NS_JINGLE_FT] | |
663 if not elts: | |
664 return | |
665 for elt in elts: | |
666 if elt.name == "received": | |
667 pass | |
668 elif elt.name == "checksum": | |
669 # we have received the file hash, we need to parse it | |
670 if content_data["senders"] == session["role"]: | |
671 log.warning( | |
672 "unexpected checksum received while we are the file sender" | |
673 ) | |
674 raise exceptions.DataError | |
675 info_content_name = elt["name"] | |
676 if info_content_name != content_name: | |
677 # it was for an other content... | |
678 return | |
679 file_data = content_data["application_data"]["file_data"] | |
680 try: | |
681 file_elt = next(elt.elements(NS_JINGLE_FT, "file")) | |
682 except StopIteration: | |
683 raise exceptions.DataError | |
684 algo, file_data["given_file_hash"] = self._hash.parse_hash_elt(file_elt) | |
685 if algo != file_data.get("hash_algo"): | |
686 log.warning( | |
687 "Hash algorithm used in given hash ({peer_algo}) doesn't correspond to the one we have used ({our_algo}) [{profile}]".format( | |
688 peer_algo=algo, | |
689 our_algo=file_data.get("hash_algo"), | |
690 profile=client.profile, | |
691 ) | |
692 ) | |
693 else: | |
694 self._receiver_try_terminate( | |
695 client, session, content_name, content_data | |
696 ) | |
697 else: | |
698 raise NotImplementedError | |
699 | |
700 def jingle_terminate(self, client, action, session, content_name, reason_elt): | |
701 if reason_elt.decline: | |
702 # progress is the only way to tell to frontends that session has been declined | |
703 progress_id = self.get_progress_id(session, content_name) | |
704 self.host.bridge.progress_error( | |
705 progress_id, C.PROGRESS_ERROR_DECLINED, client.profile | |
706 ) | |
707 elif not reason_elt.success: | |
708 progress_id = self.get_progress_id(session, content_name) | |
709 first_child = reason_elt.firstChildElement() | |
710 if first_child is not None: | |
711 reason = first_child.name | |
712 if reason_elt.text is not None: | |
713 reason = f"{reason} - {reason_elt.text}" | |
714 else: | |
715 reason = C.PROGRESS_ERROR_FAILED | |
716 self.host.bridge.progress_error( | |
717 progress_id, reason, client.profile | |
718 ) | |
719 | |
720 def _send_check_sum(self, client, session, content_name, content_data): | |
721 """Send the session-info with the hash checksum""" | |
722 file_data = content_data["application_data"]["file_data"] | |
723 hasher = file_data["hash_hasher"] | |
724 hash_ = hasher.hexdigest() | |
725 log.debug("Calculated hash: {}".format(hash_)) | |
726 iq_elt, jingle_elt = self._j.build_session_info(client, session) | |
727 checksum_elt = jingle_elt.addElement((NS_JINGLE_FT, "checksum")) | |
728 checksum_elt["creator"] = content_data["creator"] | |
729 checksum_elt["name"] = content_name | |
730 file_elt = checksum_elt.addElement("file") | |
731 file_elt.addChild(self._hash.build_hash_elt(hash_)) | |
732 iq_elt.send() | |
733 | |
734 def _receiver_try_terminate( | |
735 self, client, session, content_name, content_data, last_try=False | |
736 ): | |
737 """Try to terminate the session | |
738 | |
739 This method must only be used by the receiver. | |
740 It check if transfer is finished, and hash available, | |
741 if everything is OK, it check hash and terminate the session | |
742 @param last_try(bool): if True this mean than session must be terminated even given hash is not available | |
743 @return (bool): True if session was terminated | |
744 """ | |
745 if not content_data.get("transfer_finished", False): | |
746 return False | |
747 file_data = content_data["application_data"]["file_data"] | |
748 given_hash = file_data.get("given_file_hash") | |
749 if given_hash is None: | |
750 if last_try: | |
751 log.warning( | |
752 "sender didn't sent hash checksum, we can't check the file [{profile}]".format( | |
753 profile=client.profile | |
754 ) | |
755 ) | |
756 self._j.delayed_content_terminate(client, session, content_name) | |
757 content_data["stream_object"].close() | |
758 return True | |
759 return False | |
760 hasher = file_data["hash_hasher"] | |
761 hash_ = hasher.hexdigest() | |
762 | |
763 if hash_ == given_hash: | |
764 log.info(f"Hash checked, file was successfully transfered: {hash_}") | |
765 progress_metadata = { | |
766 "hash": hash_, | |
767 "hash_algo": file_data["hash_algo"], | |
768 "hash_verified": C.BOOL_TRUE, | |
769 } | |
770 error = None | |
771 else: | |
772 log.warning("Hash mismatch, the file was not transfered correctly") | |
773 progress_metadata = None | |
774 error = "Hash mismatch: given={algo}:{given}, calculated={algo}:{our}".format( | |
775 algo=file_data["hash_algo"], given=given_hash, our=hash_ | |
776 ) | |
777 | |
778 self._j.delayed_content_terminate(client, session, content_name) | |
779 content_data["stream_object"].close(progress_metadata, error) | |
780 # we may have the last_try timer still active, so we try to cancel it | |
781 try: | |
782 content_data["last_try_timer"].cancel() | |
783 except (KeyError, internet_error.AlreadyCalled): | |
784 pass | |
785 return True | |
786 | |
787 def _finished_cb(self, __, client, session, content_name, content_data): | |
788 log.info("File transfer terminated") | |
789 if content_data["senders"] != session["role"]: | |
790 # we terminate the session only if we are the receiver, | |
791 # as recommanded in XEP-0234 §2 (after example 6) | |
792 content_data["transfer_finished"] = True | |
793 if not self._receiver_try_terminate( | |
794 client, session, content_name, content_data | |
795 ): | |
796 # we have not received the hash yet, we wait 5 more seconds | |
797 content_data["last_try_timer"] = reactor.callLater( | |
798 5, | |
799 self._receiver_try_terminate, | |
800 client, | |
801 session, | |
802 content_name, | |
803 content_data, | |
804 last_try=True, | |
805 ) | |
806 else: | |
807 # we are the sender, we send the checksum | |
808 self._send_check_sum(client, session, content_name, content_data) | |
809 content_data["stream_object"].close() | |
810 | |
811 def _finished_eb(self, failure, client, session, content_name, content_data): | |
812 log.warning("Error while streaming file: {}".format(failure)) | |
813 content_data["stream_object"].close() | |
814 self._j.content_terminate( | |
815 client, session, content_name, reason=self._j.REASON_FAILED_TRANSPORT | |
816 ) | |
817 | |
818 | |
819 @implementer(iwokkel.IDisco) | |
820 class XEP_0234_handler(XMPPHandler): | |
821 | |
822 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
823 return [disco.DiscoFeature(NS_JINGLE_FT)] | |
824 | |
825 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
826 return [] |