Mercurial > libervia-backend
comparison sat/plugins/plugin_xep_0447.py @ 3922:0ff265725489
plugin XEP-0447: handle attachment and download:
- plugin XEP-0447 can now be used in message attachments and to retrieve an attachment
- plugin attach: `attachment` being processed is added to `extra` so the handler can inspect it
- plugin attach: `size` is added to attachment
- plugin download: a whole attachment dict is now used in `download` and
`file_download`/`file_download_complete`. `download_uri` can be used as a shortcut when
just a URI is used. In addition to URI scheme handler, whole attachment handlers can now
be registered with `register_download_handler`
- plugin XEP-0363: `file_http_upload` `XEP-0363_upload_size` triggers have been renamed to
`XEP-0363_upload_pre_slot` and is now using a dict with arguments, allowing for the size
but also the filename to be modified, which is necessary for encryption (filename may
be hidden from URL this way).
- plugin XEP-0446: fix wrong element name
- plugin XEP-0447: source handler can now be registered (`url-data` is registered by
default)
- plugin XEP-0447: source parsing has been put in a separated `parse_sources_elt` method,
as it may be useful to do it independently (notably with XEP-0448)
- plugin XEP-0447: parse received message and complete attachments when suitable
- plugin XEP-0447: can now be used with message attachments
- plugin XEP-0447: can now be used with attachments download
- renamed `options` arguments to `extra` for consistency
- some style change (progressive move from legacy camelCase to PEP8 snake_case)
- some typing
rel 379
author | Goffi <goffi@goffi.org> |
---|---|
date | Thu, 06 Oct 2022 16:02:05 +0200 |
parents | 4b7106eede0c |
children | 78b5f356900c |
comparison
equal
deleted
inserted
replaced
3921:cc2705225778 | 3922:0ff265725489 |
---|---|
13 # GNU Affero General Public License for more details. | 13 # GNU Affero General Public License for more details. |
14 | 14 |
15 # You should have received a copy of the GNU Affero General Public License | 15 # You should have received a copy of the GNU Affero General Public License |
16 # along with this program. If not, see <http://www.gnu.org/licenses/>. | 16 # along with this program. If not, see <http://www.gnu.org/licenses/>. |
17 | 17 |
18 from typing import Optional, Dict, List, Tuple, Union, Any | 18 from collections import namedtuple |
19 | 19 from functools import partial |
20 import mimetypes | |
21 from pathlib import Path | |
22 from typing import Any, Callable, Dict, List, Optional, Tuple, Union | |
23 | |
24 import treq | |
25 from twisted.internet import defer | |
20 from twisted.words.xish import domish | 26 from twisted.words.xish import domish |
21 | 27 |
28 from sat.core import exceptions | |
22 from sat.core.constants import Const as C | 29 from sat.core.constants import Const as C |
30 from sat.core.core_types import SatXMPPEntity | |
23 from sat.core.i18n import _ | 31 from sat.core.i18n import _ |
24 from sat.core.log import getLogger | 32 from sat.core.log import getLogger |
25 from sat.core import exceptions | 33 from sat.tools import stream |
34 from sat.tools.web import treq_client_no_ssl | |
26 | 35 |
27 log = getLogger(__name__) | 36 log = getLogger(__name__) |
28 | 37 |
29 | 38 |
30 PLUGIN_INFO = { | 39 PLUGIN_INFO = { |
31 C.PI_NAME: "Stateless File Sharing", | 40 C.PI_NAME: "Stateless File Sharing", |
32 C.PI_IMPORT_NAME: "XEP-0447", | 41 C.PI_IMPORT_NAME: "XEP-0447", |
33 C.PI_TYPE: "XEP", | 42 C.PI_TYPE: "XEP", |
34 C.PI_MODES: C.PLUG_MODE_BOTH, | 43 C.PI_MODES: C.PLUG_MODE_BOTH, |
35 C.PI_PROTOCOLS: ["XEP-0447"], | 44 C.PI_PROTOCOLS: ["XEP-0447"], |
36 C.PI_DEPENDENCIES: ["XEP-0103", "XEP-0446"], | 45 C.PI_DEPENDENCIES: ["XEP-0103", "XEP-0334", "XEP-0446", "ATTACH", "DOWNLOAD"], |
46 C.PI_RECOMMENDATIONS: ["XEP-0363"], | |
37 C.PI_MAIN: "XEP_0447", | 47 C.PI_MAIN: "XEP_0447", |
38 C.PI_HANDLER: "no", | 48 C.PI_HANDLER: "no", |
39 C.PI_DESCRIPTION: _("""Implementation of XEP-0447 (Stateless File Sharing)"""), | 49 C.PI_DESCRIPTION: _("""Implementation of XEP-0447 (Stateless File Sharing)"""), |
40 } | 50 } |
41 | 51 |
42 NS_SFS = "urn:xmpp:sfs:0" | 52 NS_SFS = "urn:xmpp:sfs:0" |
53 SourceHandler = namedtuple("SourceHandler", ["callback", "encrypted"]) | |
43 | 54 |
44 | 55 |
45 class XEP_0447: | 56 class XEP_0447: |
46 namespace = NS_SFS | 57 namespace = NS_SFS |
47 | 58 |
48 def __init__(self, host): | 59 def __init__(self, host): |
60 self.host = host | |
49 log.info(_("XEP-0447 (Stateless File Sharing) plugin initialization")) | 61 log.info(_("XEP-0447 (Stateless File Sharing) plugin initialization")) |
50 host.registerNamespace("sfs", NS_SFS) | 62 host.registerNamespace("sfs", NS_SFS) |
63 self._sources_handlers = {} | |
51 self._u = host.plugins["XEP-0103"] | 64 self._u = host.plugins["XEP-0103"] |
65 self._hints = host.plugins["XEP-0334"] | |
52 self._m = host.plugins["XEP-0446"] | 66 self._m = host.plugins["XEP-0446"] |
67 self._http_upload = host.plugins.get("XEP-0363") | |
68 self._attach = host.plugins["ATTACH"] | |
69 self._attach.register( | |
70 self.can_handle_attachment, self.attach, priority=1000 | |
71 ) | |
72 self.register_source_handler( | |
73 self._u.namespace, "url-data", self._u.parse_url_data_elt | |
74 ) | |
75 host.plugins["DOWNLOAD"].register_download_handler(self._u.namespace, self.download) | |
76 host.trigger.add("messageReceived", self._message_received_trigger) | |
77 | |
78 def register_source_handler( | |
79 self, namespace: str, element_name: str, | |
80 callback: Callable[[domish.Element], Dict[str, Any]], | |
81 encrypted: bool = False | |
82 ) -> None: | |
83 """Register a handler for file source | |
84 | |
85 @param namespace: namespace of the element supported | |
86 @param element_name: name of the element supported | |
87 @param callback: method to call to parse the element | |
88 get the matching element as argument, must return the parsed data | |
89 @param encrypted: if True, the source is encrypted (the transmitting channel | |
90 should then be end2end encrypted to avoir leaking decrypting data to servers). | |
91 """ | |
92 key = (namespace, element_name) | |
93 if key in self._sources_handlers: | |
94 raise exceptions.ConflictError( | |
95 f"There is already a resource handler for namespace {namespace!r} and " | |
96 f"name {element_name!r}" | |
97 ) | |
98 self._sources_handlers[key] = SourceHandler(callback, encrypted) | |
99 | |
100 async def download( | |
101 self, | |
102 client: SatXMPPEntity, | |
103 attachment: Dict[str, Any], | |
104 source: Dict[str, Any], | |
105 dest_path: Union[Path, str], | |
106 extra: Optional[Dict[str, Any]] = None | |
107 ) -> Tuple[str, defer.Deferred]: | |
108 # TODO: handle url-data headers | |
109 if extra is None: | |
110 extra = {} | |
111 try: | |
112 download_url = source["url"] | |
113 except KeyError: | |
114 raise ValueError(f"{source} has missing URL") | |
115 | |
116 if extra.get('ignore_tls_errors', False): | |
117 log.warning( | |
118 "TLS certificate check disabled, this is highly insecure" | |
119 ) | |
120 treq_client = treq_client_no_ssl | |
121 else: | |
122 treq_client = treq | |
123 | |
124 try: | |
125 file_size = int(attachment["size"]) | |
126 except (KeyError, ValueError): | |
127 head_data = await treq_client.head(download_url) | |
128 file_size = int(head_data.headers.getRawHeaders('content-length')[0]) | |
129 | |
130 file_obj = stream.SatFile( | |
131 self.host, | |
132 client, | |
133 dest_path, | |
134 mode="wb", | |
135 size = file_size, | |
136 ) | |
137 | |
138 progress_id = file_obj.uid | |
139 | |
140 resp = await treq_client.get(download_url, unbuffered=True) | |
141 if resp.code == 200: | |
142 d = treq.collect(resp, file_obj.write) | |
143 d.addCallback(lambda __: file_obj.close()) | |
144 else: | |
145 d = defer.Deferred() | |
146 self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp) | |
147 return progress_id, d | |
148 | |
149 async def can_handle_attachment(self, client, data): | |
150 if self._http_upload is None: | |
151 return False | |
152 try: | |
153 await self._http_upload.getHTTPUploadEntity(client) | |
154 except exceptions.NotFound: | |
155 return False | |
156 else: | |
157 return True | |
158 | |
159 def get_sources_elt( | |
160 self, | |
161 children: Optional[List[domish.Element]] = None | |
162 ) -> domish.Element: | |
163 """Generate <sources> element""" | |
164 sources_elt = domish.Element((NS_SFS, "sources")) | |
165 if children: | |
166 for child in children: | |
167 sources_elt.addChild(child) | |
168 return sources_elt | |
53 | 169 |
54 def get_file_sharing_elt( | 170 def get_file_sharing_elt( |
55 self, | 171 self, |
56 sources: List[Dict[str, Any]], | 172 sources: List[Dict[str, Any]], |
57 disposition: Optional[str] = None, | 173 disposition: Optional[str] = None, |
73 @return: ``<sfs/>`` element | 189 @return: ``<sfs/>`` element |
74 """ | 190 """ |
75 file_sharing_elt = domish.Element((NS_SFS, "file-sharing")) | 191 file_sharing_elt = domish.Element((NS_SFS, "file-sharing")) |
76 if disposition is not None: | 192 if disposition is not None: |
77 file_sharing_elt["disposition"] = disposition | 193 file_sharing_elt["disposition"] = disposition |
194 if media_type is None and name: | |
195 media_type = mimetypes.guess_type(name, strict=False)[0] | |
78 file_sharing_elt.addChild( | 196 file_sharing_elt.addChild( |
79 self._m.get_file_metadata_elt( | 197 self._m.get_file_metadata_elt( |
80 name=name, | 198 name=name, |
81 media_type=media_type, | 199 media_type=media_type, |
82 desc=desc, | 200 desc=desc, |
87 height=height, | 205 height=height, |
88 length=length, | 206 length=length, |
89 thumbnail=thumbnail, | 207 thumbnail=thumbnail, |
90 ) | 208 ) |
91 ) | 209 ) |
92 sources_elt = file_sharing_elt.addElement("sources") | 210 sources_elt = self.get_sources_elt() |
211 file_sharing_elt.addChild(sources_elt) | |
93 for source_data in sources: | 212 for source_data in sources: |
94 if "url" in source_data: | 213 if "url" in source_data: |
95 sources_elt.addChild( | 214 sources_elt.addChild( |
96 self._u.get_url_data_elt(**source_data) | 215 self._u.get_url_data_elt(**source_data) |
97 ) | 216 ) |
99 raise NotImplementedError( | 218 raise NotImplementedError( |
100 f"source data not implemented: {source_data}" | 219 f"source data not implemented: {source_data}" |
101 ) | 220 ) |
102 | 221 |
103 return file_sharing_elt | 222 return file_sharing_elt |
223 | |
224 def parse_sources_elt( | |
225 self, | |
226 sources_elt: domish.Element | |
227 ) -> List[Dict[str, Any]]: | |
228 """Parse <sources/> element | |
229 | |
230 @param sources_elt: <sources/> element, or a direct parent element | |
231 @return: list of found sources data | |
232 @raise: exceptions.NotFound: Can't find <sources/> element | |
233 """ | |
234 if sources_elt.name != "sources" or sources_elt.uri != NS_SFS: | |
235 try: | |
236 sources_elt = next(sources_elt.elements(NS_SFS, "sources")) | |
237 except StopIteration: | |
238 raise exceptions.NotFound( | |
239 f"<sources/> element is missing: {sources_elt.toXml()}") | |
240 sources = [] | |
241 for elt in sources_elt.elements(): | |
242 if not elt.uri: | |
243 log.warning("ignoring source element {elt.toXml()}") | |
244 continue | |
245 key = (elt.uri, elt.name) | |
246 try: | |
247 source_handler = self._sources_handlers[key] | |
248 except KeyError: | |
249 log.warning(f"unmanaged file sharing element: {elt.toXml}") | |
250 continue | |
251 else: | |
252 source_data = source_handler.callback(elt) | |
253 if source_handler.encrypted: | |
254 source_data[C.MESS_KEY_ENCRYPTED] = True | |
255 if "type" not in source_data: | |
256 source_data["type"] = elt.uri | |
257 sources.append(source_data) | |
258 return sources | |
104 | 259 |
105 def parse_file_sharing_elt( | 260 def parse_file_sharing_elt( |
106 self, | 261 self, |
107 file_sharing_elt: domish.Element | 262 file_sharing_elt: domish.Element |
108 ) -> Dict[str, Any]: | 263 ) -> Dict[str, Any]: |
124 except exceptions.NotFound: | 279 except exceptions.NotFound: |
125 data = {} | 280 data = {} |
126 disposition = file_sharing_elt.getAttribute("disposition") | 281 disposition = file_sharing_elt.getAttribute("disposition") |
127 if disposition is not None: | 282 if disposition is not None: |
128 data["disposition"] = disposition | 283 data["disposition"] = disposition |
129 sources = data["sources"] = [] | 284 try: |
130 try: | 285 data["sources"] = self.parse_sources_elt(file_sharing_elt) |
131 sources_elt = next(file_sharing_elt.elements(NS_SFS, "sources")) | 286 except exceptions.NotFound as e: |
132 except StopIteration: | 287 raise ValueError(str(e)) |
133 raise ValueError(f"<sources/> element is missing: {file_sharing_elt.toXml()}") | |
134 for elt in sources_elt.elements(): | |
135 if elt.name == "url-data" and elt.uri == self._u.namespace: | |
136 source_data = self._u.parse_url_data_elt(elt) | |
137 else: | |
138 log.warning(f"unmanaged file sharing element: {elt.toXml}") | |
139 continue | |
140 sources.append(source_data) | |
141 | 288 |
142 return data | 289 return data |
290 | |
291 def _add_file_sharing_attachments( | |
292 self, | |
293 client: SatXMPPEntity, | |
294 message_elt: domish.Element, | |
295 data: Dict[str, Any] | |
296 ) -> Dict[str, Any]: | |
297 """Check <message> for a shared file, and add it as an attachment""" | |
298 # XXX: XEP-0447 doesn't support several attachments in a single message, thus only | |
299 # one attachment can be added | |
300 try: | |
301 attachment = self.parse_file_sharing_elt(message_elt) | |
302 except exceptions.NotFound: | |
303 return data | |
304 | |
305 if any( | |
306 s.get(C.MESS_KEY_ENCRYPTED, False) | |
307 for s in attachment["sources"] | |
308 ) and client.encryption.isEncrypted(data): | |
309 # we don't add the encrypted flag if the message itself is not encrypted, | |
310 # because the decryption key is part of the link, so sending it over | |
311 # unencrypted channel is like having no encryption at all. | |
312 attachment[C.MESS_KEY_ENCRYPTED] = True | |
313 | |
314 attachments = data['extra'].setdefault(C.MESS_KEY_ATTACHMENTS, []) | |
315 attachments.append(attachment) | |
316 | |
317 return data | |
318 | |
319 async def attach(self, client, data): | |
320 # XXX: for now, XEP-0447 only allow to send one file per <message/>, thus we need | |
321 # to send each file in a separate message | |
322 attachments = data["extra"][C.MESS_KEY_ATTACHMENTS] | |
323 if not data['message'] or data['message'] == {'': ''}: | |
324 extra_attachments = attachments[1:] | |
325 del attachments[1:] | |
326 else: | |
327 # we have a message, we must send first attachment separately | |
328 extra_attachments = attachments[:] | |
329 attachments.clear() | |
330 del data["extra"][C.MESS_KEY_ATTACHMENTS] | |
331 | |
332 if attachments: | |
333 if len(attachments) > 1: | |
334 raise exceptions.InternalError( | |
335 "There should not be more that one attachment at this point" | |
336 ) | |
337 await self._attach.upload_files(client, data) | |
338 self._hints.addHintElements(data["xml"], [self._hints.HINT_STORE]) | |
339 for attachment in attachments: | |
340 try: | |
341 file_hash = (attachment["hash_algo"], attachment["hash"]) | |
342 except KeyError: | |
343 file_hash = None | |
344 file_sharing_elt = self.get_file_sharing_elt( | |
345 [{"url": attachment["url"]}], | |
346 name=attachment["name"], | |
347 size=attachment["size"], | |
348 file_hash=file_hash | |
349 ) | |
350 data["xml"].addChild(file_sharing_elt) | |
351 | |
352 for attachment in extra_attachments: | |
353 # we send all remaining attachment in a separate message | |
354 await client.sendMessage( | |
355 to_jid=data['to'], | |
356 message={'': ''}, | |
357 subject=data['subject'], | |
358 mess_type=data['type'], | |
359 extra={C.MESS_KEY_ATTACHMENTS: [attachment]}, | |
360 ) | |
361 | |
362 if ((not data['extra'] | |
363 and (not data['message'] or data['message'] == {'': ''}) | |
364 and not data['subject'])): | |
365 # nothing left to send, we can cancel the message | |
366 raise exceptions.CancelError("Cancelled by XEP_0447 attachment handling") | |
367 | |
368 def _message_received_trigger(self, client, message_elt, post_treat): | |
369 # we use a post_treat callback instead of "message_parse" trigger because we need | |
370 # to check if the "encrypted" flag is set to decide if we add the same flag to the | |
371 # attachment | |
372 post_treat.addCallback( | |
373 partial(self._add_file_sharing_attachments, client, message_elt) | |
374 ) | |
375 return True |