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 []