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