Mercurial > libervia-backend
comparison libervia/backend/plugins/plugin_xep_0363.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_0363.py@524856bd7b19 |
children | 4a8b29ab34c0 |
comparison
equal
deleted
inserted
replaced
4070:d10748475025 | 4071:4b842c1fb686 |
---|---|
1 #!/usr/bin/env python3 | |
2 | |
3 # SàT plugin for HTTP File Upload (XEP-0363) | |
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 dataclasses import dataclass | |
20 import mimetypes | |
21 import os.path | |
22 from pathlib import Path | |
23 from typing import Callable, NamedTuple, Optional, Tuple | |
24 from urllib import parse | |
25 | |
26 from twisted.internet import reactor | |
27 from twisted.internet import defer | |
28 from twisted.web import client as http_client | |
29 from twisted.web import http_headers | |
30 from twisted.words.protocols.jabber import error, jid, xmlstream | |
31 from twisted.words.xish import domish | |
32 from wokkel import disco, iwokkel | |
33 from zope.interface import implementer | |
34 | |
35 from libervia.backend.core import exceptions | |
36 from libervia.backend.core.constants import Const as C | |
37 from libervia.backend.core.core_types import SatXMPPEntity | |
38 from libervia.backend.core.i18n import _ | |
39 from libervia.backend.core.log import getLogger | |
40 from libervia.backend.core.xmpp import SatXMPPComponent | |
41 from libervia.backend.tools import utils, web as sat_web | |
42 | |
43 | |
44 log = getLogger(__name__) | |
45 | |
46 PLUGIN_INFO = { | |
47 C.PI_NAME: "HTTP File Upload", | |
48 C.PI_IMPORT_NAME: "XEP-0363", | |
49 C.PI_TYPE: "XEP", | |
50 C.PI_MODES: C.PLUG_MODE_BOTH, | |
51 C.PI_PROTOCOLS: ["XEP-0363"], | |
52 C.PI_DEPENDENCIES: ["FILE", "UPLOAD"], | |
53 C.PI_MAIN: "XEP_0363", | |
54 C.PI_HANDLER: "yes", | |
55 C.PI_DESCRIPTION: _("""Implementation of HTTP File Upload"""), | |
56 } | |
57 | |
58 NS_HTTP_UPLOAD = "urn:xmpp:http:upload:0" | |
59 IQ_HTTP_UPLOAD_REQUEST = C.IQ_GET + '/request[@xmlns="' + NS_HTTP_UPLOAD + '"]' | |
60 ALLOWED_HEADERS = ('authorization', 'cookie', 'expires') | |
61 | |
62 | |
63 @dataclass | |
64 class Slot: | |
65 """Upload slot""" | |
66 put: str | |
67 get: str | |
68 headers: list | |
69 | |
70 | |
71 class UploadRequest(NamedTuple): | |
72 from_: jid.JID | |
73 filename: str | |
74 size: int | |
75 content_type: Optional[str] | |
76 | |
77 | |
78 class RequestHandler(NamedTuple): | |
79 callback: Callable[[SatXMPPComponent, UploadRequest], Optional[Slot]] | |
80 priority: int | |
81 | |
82 | |
83 class XEP_0363: | |
84 Slot=Slot | |
85 | |
86 def __init__(self, host): | |
87 log.info(_("plugin HTTP File Upload initialization")) | |
88 self.host = host | |
89 host.bridge.add_method( | |
90 "file_http_upload", | |
91 ".plugin", | |
92 in_sign="sssbs", | |
93 out_sign="", | |
94 method=self._file_http_upload, | |
95 ) | |
96 host.bridge.add_method( | |
97 "file_http_upload_get_slot", | |
98 ".plugin", | |
99 in_sign="sisss", | |
100 out_sign="(ssaa{ss})", | |
101 method=self._get_slot, | |
102 async_=True, | |
103 ) | |
104 host.plugins["UPLOAD"].register( | |
105 "HTTP Upload", self.get_http_upload_entity, self.file_http_upload | |
106 ) | |
107 # list of callbacks used when a request is done to a component | |
108 self.handlers = [] | |
109 # XXX: there is not yet official short name, so we use "http_upload" | |
110 host.register_namespace("http_upload", NS_HTTP_UPLOAD) | |
111 | |
112 def get_handler(self, client): | |
113 return XEP_0363_handler(self) | |
114 | |
115 def register_handler(self, callback, priority=0): | |
116 """Register a request handler | |
117 | |
118 @param callack: method to call when a request is done | |
119 the callback must return a Slot if the request is handled, | |
120 otherwise, other callbacks will be tried. | |
121 If the callback raises a StanzaError, its condition will be used if no other | |
122 callback can handle the request. | |
123 @param priority: handlers with higher priorities will be called first | |
124 """ | |
125 assert callback not in self.handlers | |
126 req_handler = RequestHandler(callback, priority) | |
127 self.handlers.append(req_handler) | |
128 self.handlers.sort(key=lambda handler: handler.priority, reverse=True) | |
129 | |
130 def get_file_too_large_elt(self, max_size: int) -> domish.Element: | |
131 """Generate <file-too-large> app condition for errors""" | |
132 file_too_large_elt = domish.Element((NS_HTTP_UPLOAD, "file-too-large")) | |
133 file_too_large_elt.addElement("max-file-size", str(max_size)) | |
134 return file_too_large_elt | |
135 | |
136 async def get_http_upload_entity(self, client, upload_jid=None): | |
137 """Get HTTP upload capable entity | |
138 | |
139 upload_jid is checked, then its components | |
140 @param upload_jid(None, jid.JID): entity to check | |
141 @return(D(jid.JID)): first HTTP upload capable entity | |
142 @raise exceptions.NotFound: no entity found | |
143 """ | |
144 try: | |
145 entity = client.http_upload_service | |
146 except AttributeError: | |
147 found_entities = await self.host.find_features_set(client, (NS_HTTP_UPLOAD,)) | |
148 try: | |
149 entity = client.http_upload_service = next(iter(found_entities)) | |
150 except StopIteration: | |
151 entity = client.http_upload_service = None | |
152 | |
153 if entity is None: | |
154 raise exceptions.NotFound("No HTTP upload entity found") | |
155 | |
156 return entity | |
157 | |
158 def _file_http_upload(self, filepath, filename="", upload_jid="", | |
159 ignore_tls_errors=False, profile=C.PROF_KEY_NONE): | |
160 assert os.path.isabs(filepath) and os.path.isfile(filepath) | |
161 client = self.host.get_client(profile) | |
162 return defer.ensureDeferred(self.file_http_upload( | |
163 client, | |
164 filepath, | |
165 filename or None, | |
166 jid.JID(upload_jid) if upload_jid else None, | |
167 {"ignore_tls_errors": ignore_tls_errors}, | |
168 )) | |
169 | |
170 async def file_http_upload( | |
171 self, | |
172 client: SatXMPPEntity, | |
173 filepath: Path, | |
174 filename: Optional[str] = None, | |
175 upload_jid: Optional[jid.JID] = None, | |
176 extra: Optional[dict] = None | |
177 ) -> Tuple[str, defer.Deferred]: | |
178 """Upload a file through HTTP | |
179 | |
180 @param filepath: absolute path of the file | |
181 @param filename: name to use for the upload | |
182 None to use basename of the path | |
183 @param upload_jid: upload capable entity jid, | |
184 or None to use autodetected, if possible | |
185 @param extra: options where key can be: | |
186 - ignore_tls_errors(bool): if True, SSL certificate will not be checked | |
187 - attachment(dict): file attachment data | |
188 @param profile: %(doc_profile)s | |
189 @return: progress id and Deferred which fire download URL | |
190 """ | |
191 if extra is None: | |
192 extra = {} | |
193 ignore_tls_errors = extra.get("ignore_tls_errors", False) | |
194 file_metadata = { | |
195 "filename": filename or os.path.basename(filepath), | |
196 "filepath": filepath, | |
197 "size": os.path.getsize(filepath), | |
198 } | |
199 | |
200 #: this trigger can be used to modify the filename or size requested when geting | |
201 #: the slot, it is notably useful with encryption. | |
202 self.host.trigger.point( | |
203 "XEP-0363_upload_pre_slot", client, extra, file_metadata, | |
204 triggers_no_cancel=True | |
205 ) | |
206 try: | |
207 slot = await self.get_slot( | |
208 client, file_metadata["filename"], file_metadata["size"], | |
209 upload_jid=upload_jid | |
210 ) | |
211 except Exception as e: | |
212 log.warning(_("Can't get upload slot: {reason}").format(reason=e)) | |
213 raise e | |
214 else: | |
215 log.debug(f"Got upload slot: {slot}") | |
216 sat_file = self.host.plugins["FILE"].File( | |
217 self.host, client, filepath, uid=extra.get("progress_id"), | |
218 size=file_metadata["size"], | |
219 auto_end_signals=False | |
220 ) | |
221 progress_id = sat_file.uid | |
222 | |
223 file_producer = http_client.FileBodyProducer(sat_file) | |
224 | |
225 if ignore_tls_errors: | |
226 agent = http_client.Agent(reactor, sat_web.NoCheckContextFactory()) | |
227 else: | |
228 agent = http_client.Agent(reactor) | |
229 | |
230 headers = {"User-Agent": [C.APP_NAME.encode("utf-8")]} | |
231 | |
232 for name, value in slot.headers: | |
233 name = name.encode('utf-8') | |
234 value = value.encode('utf-8') | |
235 headers[name] = value | |
236 | |
237 | |
238 await self.host.trigger.async_point( | |
239 "XEP-0363_upload", client, extra, sat_file, file_producer, slot, | |
240 triggers_no_cancel=True) | |
241 | |
242 download_d = agent.request( | |
243 b"PUT", | |
244 slot.put.encode("utf-8"), | |
245 http_headers.Headers(headers), | |
246 file_producer, | |
247 ) | |
248 download_d.addCallbacks( | |
249 self._upload_cb, | |
250 self._upload_eb, | |
251 (sat_file, slot), | |
252 None, | |
253 (sat_file,), | |
254 ) | |
255 | |
256 return progress_id, download_d | |
257 | |
258 def _upload_cb(self, __, sat_file, slot): | |
259 """Called once file is successfully uploaded | |
260 | |
261 @param sat_file(SatFile): file used for the upload | |
262 should be closed, but it is needed to send the progress_finished signal | |
263 @param slot(Slot): put/get urls | |
264 """ | |
265 log.info(f"HTTP upload finished ({slot.get})") | |
266 sat_file.progress_finished({"url": slot.get}) | |
267 return slot.get | |
268 | |
269 def _upload_eb(self, failure_, sat_file): | |
270 """Called on unsuccessful upload | |
271 | |
272 @param sat_file(SatFile): file used for the upload | |
273 should be closed, be is needed to send the progress_error signal | |
274 """ | |
275 try: | |
276 wrapped_fail = failure_.value.reasons[0] | |
277 except (AttributeError, IndexError) as e: | |
278 log.warning(_("upload failed: {reason}").format(reason=e)) | |
279 sat_file.progress_error(str(failure_)) | |
280 else: | |
281 if wrapped_fail.check(sat_web.SSLError): | |
282 msg = "TLS validation error, can't connect to HTTPS server" | |
283 else: | |
284 msg = "can't upload file" | |
285 log.warning(msg + ": " + str(wrapped_fail.value)) | |
286 sat_file.progress_error(msg) | |
287 raise failure_ | |
288 | |
289 def _get_slot(self, filename, size, content_type, upload_jid, | |
290 profile_key=C.PROF_KEY_NONE): | |
291 """Get an upload slot | |
292 | |
293 This method can be used when uploading is done by the frontend | |
294 @param filename(unicode): name of the file to upload | |
295 @param size(int): size of the file (must be non null) | |
296 @param upload_jid(str, ''): HTTP upload capable entity | |
297 @param content_type(unicode, None): MIME type of the content | |
298 empty string or None to guess automatically | |
299 """ | |
300 client = self.host.get_client(profile_key) | |
301 filename = filename.replace("/", "_") | |
302 d = defer.ensureDeferred(self.get_slot( | |
303 client, filename, size, content_type or None, jid.JID(upload_jid) or None | |
304 )) | |
305 d.addCallback(lambda slot: (slot.get, slot.put, slot.headers)) | |
306 return d | |
307 | |
308 async def get_slot(self, client, filename, size, content_type=None, upload_jid=None): | |
309 """Get a slot (i.e. download/upload links) | |
310 | |
311 @param filename(unicode): name to use for the upload | |
312 @param size(int): size of the file to upload (must be >0) | |
313 @param content_type(None, unicode): MIME type of the content | |
314 None to autodetect | |
315 @param upload_jid(jid.JID, None): HTTP upload capable upload_jid | |
316 or None to use the server component (if any) | |
317 @param client: %(doc_client)s | |
318 @return (Slot): the upload (put) and download (get) URLs | |
319 @raise exceptions.NotFound: no HTTP upload capable upload_jid has been found | |
320 """ | |
321 assert filename and size | |
322 if content_type is None: | |
323 # TODO: manage python magic for file guessing (in a dedicated plugin ?) | |
324 content_type = mimetypes.guess_type(filename, strict=False)[0] | |
325 | |
326 if upload_jid is None: | |
327 try: | |
328 upload_jid = client.http_upload_service | |
329 except AttributeError: | |
330 found_entity = await self.get_http_upload_entity(client) | |
331 return await self.get_slot( | |
332 client, filename, size, content_type, found_entity) | |
333 else: | |
334 if upload_jid is None: | |
335 raise exceptions.NotFound("No HTTP upload entity found") | |
336 | |
337 iq_elt = client.IQ("get") | |
338 iq_elt["to"] = upload_jid.full() | |
339 request_elt = iq_elt.addElement((NS_HTTP_UPLOAD, "request")) | |
340 request_elt["filename"] = filename | |
341 request_elt["size"] = str(size) | |
342 if content_type is not None: | |
343 request_elt["content-type"] = content_type | |
344 | |
345 iq_result_elt = await iq_elt.send() | |
346 | |
347 try: | |
348 slot_elt = next(iq_result_elt.elements(NS_HTTP_UPLOAD, "slot")) | |
349 put_elt = next(slot_elt.elements(NS_HTTP_UPLOAD, "put")) | |
350 put_url = put_elt['url'] | |
351 get_elt = next(slot_elt.elements(NS_HTTP_UPLOAD, "get")) | |
352 get_url = get_elt['url'] | |
353 except (StopIteration, KeyError): | |
354 raise exceptions.DataError("Incorrect stanza received from server") | |
355 | |
356 headers = [] | |
357 for header_elt in put_elt.elements(NS_HTTP_UPLOAD, "header"): | |
358 try: | |
359 name = header_elt["name"] | |
360 value = str(header_elt) | |
361 except KeyError: | |
362 log.warning(_("Invalid header element: {xml}").format( | |
363 iq_result_elt.toXml())) | |
364 continue | |
365 name = name.replace('\n', '') | |
366 value = value.replace('\n', '') | |
367 if name.lower() not in ALLOWED_HEADERS: | |
368 log.warning(_('Ignoring unauthorised header "{name}": {xml}') | |
369 .format(name=name, xml = iq_result_elt.toXml())) | |
370 continue | |
371 headers.append((name, value)) | |
372 | |
373 return Slot(put=put_url, get=get_url, headers=headers) | |
374 | |
375 # component | |
376 | |
377 def on_component_request(self, iq_elt, client): | |
378 iq_elt.handled=True | |
379 defer.ensureDeferred(self.handle_component_request(client, iq_elt)) | |
380 | |
381 async def handle_component_request(self, client, iq_elt): | |
382 try: | |
383 request_elt = next(iq_elt.elements(NS_HTTP_UPLOAD, "request")) | |
384 request = UploadRequest( | |
385 from_=jid.JID(iq_elt['from']), | |
386 filename=parse.quote(request_elt['filename'].replace('/', '_'), safe=''), | |
387 size=int(request_elt['size']), | |
388 content_type=request_elt.getAttribute('content-type') | |
389 ) | |
390 except (StopIteration, KeyError, ValueError): | |
391 client.sendError(iq_elt, "bad-request") | |
392 return | |
393 | |
394 err = None | |
395 | |
396 for handler in self.handlers: | |
397 try: | |
398 slot = await utils.as_deferred(handler.callback, client, request) | |
399 except error.StanzaError as e: | |
400 log.warning( | |
401 "a stanza error has been raised while processing HTTP Upload of " | |
402 f"request: {e}" | |
403 ) | |
404 if err is None: | |
405 # we keep the first error to return its condition later, | |
406 # if no other callback handle the request | |
407 err = e | |
408 else: | |
409 if slot: | |
410 break | |
411 else: | |
412 log.warning( | |
413 _("no service can handle HTTP Upload request: {elt}") | |
414 .format(elt=iq_elt.toXml())) | |
415 if err is None: | |
416 err = error.StanzaError("feature-not-implemented") | |
417 client.send(err.toResponse(iq_elt)) | |
418 return | |
419 | |
420 iq_result_elt = xmlstream.toResponse(iq_elt, "result") | |
421 slot_elt = iq_result_elt.addElement((NS_HTTP_UPLOAD, 'slot')) | |
422 put_elt = slot_elt.addElement('put') | |
423 put_elt['url'] = slot.put | |
424 get_elt = slot_elt.addElement('get') | |
425 get_elt['url'] = slot.get | |
426 client.send(iq_result_elt) | |
427 | |
428 | |
429 @implementer(iwokkel.IDisco) | |
430 class XEP_0363_handler(xmlstream.XMPPHandler): | |
431 | |
432 def __init__(self, plugin_parent): | |
433 self.plugin_parent = plugin_parent | |
434 | |
435 def connectionInitialized(self): | |
436 if ((self.parent.is_component | |
437 and PLUGIN_INFO[C.PI_IMPORT_NAME] in self.parent.enabled_features)): | |
438 self.xmlstream.addObserver( | |
439 IQ_HTTP_UPLOAD_REQUEST, self.plugin_parent.on_component_request, | |
440 client=self.parent | |
441 ) | |
442 | |
443 def getDiscoInfo(self, requestor, target, nodeIdentifier=""): | |
444 if ((self.parent.is_component | |
445 and not PLUGIN_INFO[C.PI_IMPORT_NAME] in self.parent.enabled_features)): | |
446 return [] | |
447 else: | |
448 return [disco.DiscoFeature(NS_HTTP_UPLOAD)] | |
449 | |
450 def getDiscoItems(self, requestor, target, nodeIdentifier=""): | |
451 return [] |