Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0447.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_0447.py@c23cad65ae99 |
children | 0d7bb4df2343 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org) | |
4 | |
5 # This program is free software: you can redistribute it and/or modify | |
6 # it under the terms of the GNU Affero General Public License as published by | |
7 # the Free Software Foundation, either version 3 of the License, or | |
8 # (at your option) any later version. | |
9 | |
10 # This program is distributed in the hope that it will be useful, | |
11 # but WITHOUT ANY WARRANTY; without even the implied warranty of | |
12 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the | |
13 # GNU Affero General Public License for more details. | |
14 | |
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/>. | |
17 | |
18 from collections import namedtuple | |
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 | |
26 from twisted.words.xish import domish | |
27 | |
28 from libervia.backend.core import exceptions | |
29 from libervia.backend.core.constants import Const as C | |
30 from libervia.backend.core.core_types import SatXMPPEntity | |
31 from libervia.backend.core.i18n import _ | |
32 from libervia.backend.core.log import getLogger | |
33 from libervia.backend.tools import stream | |
34 from libervia.backend.tools.web import treq_client_no_ssl | |
35 | |
36 log = getLogger(__name__) | |
37 | |
38 | |
39 PLUGIN_INFO = { | |
40 C.PI_NAME: "Stateless File Sharing", | |
41 C.PI_IMPORT_NAME: "XEP-0447", | |
42 C.PI_TYPE: "XEP", | |
43 C.PI_MODES: C.PLUG_MODE_BOTH, | |
44 C.PI_PROTOCOLS: ["XEP-0447"], | |
45 C.PI_DEPENDENCIES: ["XEP-0103", "XEP-0334", "XEP-0446", "ATTACH", "DOWNLOAD"], | |
46 C.PI_RECOMMENDATIONS: ["XEP-0363"], | |
47 C.PI_MAIN: "XEP_0447", | |
48 C.PI_HANDLER: "no", | |
49 C.PI_DESCRIPTION: _("""Implementation of XEP-0447 (Stateless File Sharing)"""), | |
50 } | |
51 | |
52 NS_SFS = "urn:xmpp:sfs:0" | |
53 SourceHandler = namedtuple("SourceHandler", ["callback", "encrypted"]) | |
54 | |
55 | |
56 class XEP_0447: | |
57 namespace = NS_SFS | |
58 | |
59 def __init__(self, host): | |
60 self.host = host | |
61 log.info(_("XEP-0447 (Stateless File Sharing) plugin initialization")) | |
62 host.register_namespace("sfs", NS_SFS) | |
63 self._sources_handlers = {} | |
64 self._u = host.plugins["XEP-0103"] | |
65 self._hints = host.plugins["XEP-0334"] | |
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("message_received", 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.get_http_upload_entity(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 | |
169 | |
170 def get_file_sharing_elt( | |
171 self, | |
172 sources: List[Dict[str, Any]], | |
173 disposition: Optional[str] = None, | |
174 name: Optional[str] = None, | |
175 media_type: Optional[str] = None, | |
176 desc: Optional[str] = None, | |
177 size: Optional[int] = None, | |
178 file_hash: Optional[Tuple[str, str]] = None, | |
179 date: Optional[Union[float, int]] = None, | |
180 width: Optional[int] = None, | |
181 height: Optional[int] = None, | |
182 length: Optional[int] = None, | |
183 thumbnail: Optional[str] = None, | |
184 **kwargs, | |
185 ) -> domish.Element: | |
186 """Generate the <file-sharing/> element | |
187 | |
188 @param extra: extra metadata describing how to access the URL | |
189 @return: ``<sfs/>`` element | |
190 """ | |
191 file_sharing_elt = domish.Element((NS_SFS, "file-sharing")) | |
192 if disposition is not None: | |
193 file_sharing_elt["disposition"] = disposition | |
194 if media_type is None and name: | |
195 media_type = mimetypes.guess_type(name, strict=False)[0] | |
196 file_sharing_elt.addChild( | |
197 self._m.get_file_metadata_elt( | |
198 name=name, | |
199 media_type=media_type, | |
200 desc=desc, | |
201 size=size, | |
202 file_hash=file_hash, | |
203 date=date, | |
204 width=width, | |
205 height=height, | |
206 length=length, | |
207 thumbnail=thumbnail, | |
208 ) | |
209 ) | |
210 sources_elt = self.get_sources_elt() | |
211 file_sharing_elt.addChild(sources_elt) | |
212 for source_data in sources: | |
213 if "url" in source_data: | |
214 sources_elt.addChild( | |
215 self._u.get_url_data_elt(**source_data) | |
216 ) | |
217 else: | |
218 raise NotImplementedError( | |
219 f"source data not implemented: {source_data}" | |
220 ) | |
221 | |
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 | |
259 | |
260 def parse_file_sharing_elt( | |
261 self, | |
262 file_sharing_elt: domish.Element | |
263 ) -> Dict[str, Any]: | |
264 """Parse <file-sharing/> element and return file-sharing data | |
265 | |
266 @param file_sharing_elt: <file-sharing/> element | |
267 @return: file-sharing data. It a dict whose keys correspond to | |
268 [get_file_sharing_elt] parameters | |
269 """ | |
270 if file_sharing_elt.name != "file-sharing" or file_sharing_elt.uri != NS_SFS: | |
271 try: | |
272 file_sharing_elt = next( | |
273 file_sharing_elt.elements(NS_SFS, "file-sharing") | |
274 ) | |
275 except StopIteration: | |
276 raise exceptions.NotFound | |
277 try: | |
278 data = self._m.parse_file_metadata_elt(file_sharing_elt) | |
279 except exceptions.NotFound: | |
280 data = {} | |
281 disposition = file_sharing_elt.getAttribute("disposition") | |
282 if disposition is not None: | |
283 data["disposition"] = disposition | |
284 try: | |
285 data["sources"] = self.parse_sources_elt(file_sharing_elt) | |
286 except exceptions.NotFound as e: | |
287 raise ValueError(str(e)) | |
288 | |
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, for now | |
299 # however that should be fixed in future version, and so we accept several | |
300 # <file-sharing> element in a message. | |
301 for file_sharing_elt in message_elt.elements(NS_SFS, "file-sharing"): | |
302 attachment = self.parse_file_sharing_elt(message_elt) | |
303 | |
304 if any( | |
305 s.get(C.MESS_KEY_ENCRYPTED, False) | |
306 for s in attachment["sources"] | |
307 ) and client.encryption.isEncrypted(data): | |
308 # we don't add the encrypted flag if the message itself is not encrypted, | |
309 # because the decryption key is part of the link, so sending it over | |
310 # unencrypted channel is like having no encryption at all. | |
311 attachment[C.MESS_KEY_ENCRYPTED] = True | |
312 | |
313 attachments = data['extra'].setdefault(C.KEY_ATTACHMENTS, []) | |
314 attachments.append(attachment) | |
315 | |
316 return data | |
317 | |
318 async def attach(self, client, data): | |
319 # XXX: for now, XEP-0447 only allow to send one file per <message/>, thus we need | |
320 # to send each file in a separate message | |
321 attachments = data["extra"][C.KEY_ATTACHMENTS] | |
322 if not data['message'] or data['message'] == {'': ''}: | |
323 extra_attachments = attachments[1:] | |
324 del attachments[1:] | |
325 else: | |
326 # we have a message, we must send first attachment separately | |
327 extra_attachments = attachments[:] | |
328 attachments.clear() | |
329 del data["extra"][C.KEY_ATTACHMENTS] | |
330 | |
331 if attachments: | |
332 if len(attachments) > 1: | |
333 raise exceptions.InternalError( | |
334 "There should not be more that one attachment at this point" | |
335 ) | |
336 await self._attach.upload_files(client, data) | |
337 self._hints.add_hint_elements(data["xml"], [self._hints.HINT_STORE]) | |
338 for attachment in attachments: | |
339 try: | |
340 file_hash = (attachment["hash_algo"], attachment["hash"]) | |
341 except KeyError: | |
342 file_hash = None | |
343 file_sharing_elt = self.get_file_sharing_elt( | |
344 [{"url": attachment["url"]}], | |
345 name=attachment.get("name"), | |
346 size=attachment.get("size"), | |
347 desc=attachment.get("desc"), | |
348 media_type=attachment.get("media_type"), | |
349 file_hash=file_hash | |
350 ) | |
351 data["xml"].addChild(file_sharing_elt) | |
352 | |
353 for attachment in extra_attachments: | |
354 # we send all remaining attachment in a separate message | |
355 await client.sendMessage( | |
356 to_jid=data['to'], | |
357 message={'': ''}, | |
358 subject=data['subject'], | |
359 mess_type=data['type'], | |
360 extra={C.KEY_ATTACHMENTS: [attachment]}, | |
361 ) | |
362 | |
363 if ((not data['extra'] | |
364 and (not data['message'] or data['message'] == {'': ''}) | |
365 and not data['subject'])): | |
366 # nothing left to send, we can cancel the message | |
367 raise exceptions.CancelError("Cancelled by XEP_0447 attachment handling") | |
368 | |
369 def _message_received_trigger(self, client, message_elt, post_treat): | |
370 # we use a post_treat callback instead of "message_parse" trigger because we need | |
371 # to check if the "encrypted" flag is set to decide if we add the same flag to the | |
372 # attachment | |
373 post_treat.addCallback( | |
374 partial(self._add_file_sharing_attachments, client, message_elt) | |
375 ) | |
376 return True |