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