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
This commit is contained in:
Goffi 2022-10-06 16:02:05 +02:00
commit a8fc70fca6
8 changed files with 541 additions and 166 deletions

View file

@ -307,7 +307,7 @@ class BlogImportPlugin(object):
"%", "_"
) # FIXME: tmp workaround for a bug in prosody http upload
__, download_d = yield self._u.upload(
client, tmp_file, filename, options=upload_options
client, tmp_file, filename, extra=upload_options
)
download_url = yield download_d
except Exception as e:

View file

@ -16,15 +16,19 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
from collections import namedtuple
from twisted.internet import defer
import mimetypes
import tempfile
from pathlib import Path
import shutil
from sat.core.i18n import _
import tempfile
from typing import Callable, Optional
from twisted.internet import defer
from sat.core import exceptions
from sat.core.constants import Const as C
from sat.core.core_types import SatXMPPEntity
from sat.core.i18n import _
from sat.core.log import getLogger
from sat.tools import utils
from sat.tools import image
@ -37,6 +41,7 @@ PLUGIN_INFO = {
C.PI_NAME: "File Attach",
C.PI_IMPORT_NAME: "ATTACH",
C.PI_TYPE: C.PLUG_TYPE_MISC,
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_DEPENDENCIES: ["UPLOAD"],
C.PI_MAIN: "AttachPlugin",
C.PI_HANDLER: "no",
@ -152,7 +157,12 @@ class AttachPlugin:
return data
async def uploadFiles(self, client, data, upload_cb=None):
async def upload_files(
self,
client: SatXMPPEntity,
data: dict,
upload_cb: Optional[Callable] = None
):
"""Upload file, and update attachments
invalid attachments will be removed
@ -160,7 +170,7 @@ class AttachPlugin:
@param data(dict): message data
@param upload_cb(coroutine, Deferred, None): method to use for upload
if None, upload method from UPLOAD plugin will be used.
Otherwise, following kwargs will be use with the cb:
Otherwise, following kwargs will be used with the cb:
- client
- filepath
- filename
@ -179,7 +189,7 @@ class AttachPlugin:
for attachment in attachments:
try:
# we pop path because we don't want it to be stored, as the image can be
# we pop path because we don't want it to be stored, as the file can be
# only in a temporary location
path = Path(attachment.pop("path"))
except KeyError:
@ -198,14 +208,18 @@ class AttachPlugin:
except KeyError:
name = attachment["name"] = path.name
options = {}
attachment["size"] = path.stat().st_size
extra = {
"attachment": attachment
}
progress_id = attachment.pop("progress_id", None)
if progress_id:
options["progress_id"] = progress_id
extra["progress_id"] = progress_id
check_certificate = self.host.memory.getParamA(
"check_certificate", "Connection", profile_key=client.profile)
if not check_certificate:
options['ignore_tls_errors'] = True
extra['ignore_tls_errors'] = True
log.warning(
_("certificate check disabled for upload, this is dangerous!"))
@ -213,7 +227,7 @@ class AttachPlugin:
client=client,
filepath=path,
filename=name,
options=options,
extra=extra,
)
uploads_d.append(upload_d)
@ -246,9 +260,11 @@ class AttachPlugin:
return True
async def defaultAttach(self, client, data):
await self.uploadFiles(client, data)
await self.upload_files(client, data)
# TODO: handle xhtml-im
body_elt = next(data["xml"].elements(C.NS_CLIENT, "body"))
body_elt = data["xml"].body
if body_elt is None:
body_elt = data["xml"].addElement("body")
attachments = data["extra"][C.MESS_KEY_ATTACHMENTS]
if attachments:
body_links = '\n'.join(a['url'] for a in attachments)

View file

@ -16,19 +16,23 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from pathlib import Path
from urllib.parse import urlparse, unquote
import hashlib
from pathlib import Path
from typing import Any, Dict, Optional, Union, Tuple, Callable
from urllib.parse import unquote, urlparse
import treq
from twisted.internet import defer
from twisted.words.protocols.jabber import error as jabber_error
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.core.log import getLogger
from sat.core import exceptions
from sat.core.constants import Const as C
from sat.core.core_types import SatXMPPEntity
from sat.core.i18n import D_, _
from sat.core.log import getLogger
from sat.tools import xml_tools
from sat.tools.common import data_format
from sat.tools import stream
from sat.tools.common import data_format
from sat.tools.web import treq_client_no_ssl
log = getLogger(__name__)
@ -38,6 +42,7 @@ PLUGIN_INFO = {
C.PI_NAME: "File Download",
C.PI_IMPORT_NAME: "DOWNLOAD",
C.PI_TYPE: C.PLUG_TYPE_MISC,
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_MAIN: "DownloadPlugin",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("""File download management"""),
@ -53,7 +58,7 @@ class DownloadPlugin(object):
"fileDownload",
".plugin",
in_sign="ssss",
out_sign="a{ss}",
out_sign="s",
method=self._fileDownload,
async_=True,
)
@ -66,18 +71,29 @@ class DownloadPlugin(object):
async_=True,
)
self._download_callbacks = {}
self.registerScheme('http', self.downloadHTTP)
self.registerScheme('https', self.downloadHTTP)
self._scheme_callbacks = {}
self.register_scheme('http', self.download_http)
self.register_scheme('https', self.download_http)
def _fileDownload(self, uri, dest_path, options_s, profile):
client = self.host.getClient(profile)
options = data_format.deserialise(options_s)
return defer.ensureDeferred(self.fileDownload(
client, uri, Path(dest_path), options
def _fileDownload(
self, attachment_s: str, dest_path: str, extra_s: str, profile: str
) -> defer.Deferred:
d = defer.ensureDeferred(self.file_download(
self.host.getClient(profile),
data_format.deserialise(attachment_s),
Path(dest_path),
data_format.deserialise(extra_s)
))
d.addCallback(lambda ret: data_format.serialise(ret))
return d
async def fileDownload(self, client, uri, dest_path, options=None):
async def file_download(
self,
client: SatXMPPEntity,
attachment: Dict[str, Any],
dest_path: Path,
extra: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""Download a file using best available method
parameters are the same as for [download]
@ -85,7 +101,7 @@ class DownloadPlugin(object):
message
"""
try:
progress_id, __ = await self.download(client, uri, dest_path, options)
progress_id, __ = await self.download(client, attachment, dest_path, extra)
except Exception as e:
if (isinstance(e, jabber_error.StanzaError)
and e.condition == 'not-acceptable'):
@ -102,45 +118,44 @@ class DownloadPlugin(object):
else:
return {"progress": progress_id}
def _fileDownloadComplete(self, uri, dest_path, options_s, profile):
client = self.host.getClient(profile)
options = data_format.deserialise(options_s)
d = defer.ensureDeferred(self.fileDownloadComplete(
client, uri, dest_path, options
def _fileDownloadComplete(
self, attachment_s: str, dest_path: str, extra_s: str, profile: str
) -> defer.Deferred:
d = defer.ensureDeferred(self.file_download_complete(
self.host.getClient(profile),
data_format.deserialise(attachment_s),
Path(dest_path),
data_format.deserialise(extra_s)
))
d.addCallback(lambda path: str(path))
return d
async def fileDownloadComplete(self, client, uri, dest_path, options=None):
async def file_download_complete(
self,
client: SatXMPPEntity,
attachment: Dict[str, Any],
dest_path: Path,
extra: Optional[Dict[str, Any]] = None
) -> str:
"""Helper method to fully download a file and return its path
parameters are the same as for [download]
@return (str): path to the downloaded file
use empty string to store the file in cache
"""
__, download_d = await self.download(client, uri, dest_path, options)
__, download_d = await self.download(client, attachment, dest_path, extra)
dest_path = await download_d
return dest_path
async def download(self, client, uri, dest_path, options=None):
"""Send a file using best available method
@param uri(str): URI to the file to download
@param dest_path(str, Path): where the file must be downloaded
if empty string, the file will be stored in local path
@param options(dict, None): options depending on scheme handler
Some common options:
- ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification
used only if HTTPS transport is needed
@return (tuple[unicode,D(unicode)]): progress_id and a Deferred which fire
download URL when download is finished
progress_id can be empty string if the file already exist and is not
downloaded again (can happen if cache is used with empty dest_path)
"""
if options is None:
options = {}
async def download_uri(
self,
client: SatXMPPEntity,
uri: str,
dest_path: Union[Path, str],
extra: Optional[Dict[str, Any]] = None
) -> Tuple[str, defer.Deferred]:
if extra is None:
extra = {}
uri_parsed = urlparse(uri, 'http')
if dest_path:
dest_path = Path(dest_path)
@ -171,18 +186,18 @@ class DownloadPlugin(object):
check_certificate = self.host.memory.getParamA(
"check_certificate", "Connection", profile_key=client.profile)
if not check_certificate:
options['ignore_tls_errors'] = True
extra['ignore_tls_errors'] = True
log.warning(
_("certificate check disabled for download, this is dangerous!"))
try:
callback = self._download_callbacks[uri_parsed.scheme]
callback = self._scheme_callbacks[uri_parsed.scheme]
except KeyError:
raise exceptions.NotFound(f"Can't find any handler for uri {uri}")
else:
try:
progress_id, download_d = await callback(
client, uri_parsed, dest_path, options)
client, uri_parsed, dest_path, extra)
except Exception as e:
log.warning(_(
"Can't download URI {uri}: {reason}").format(
@ -195,11 +210,96 @@ class DownloadPlugin(object):
download_d.addCallback(lambda __: dest_path)
return progress_id, download_d
def registerScheme(self, scheme, download_cb):
async def download(
self,
client: SatXMPPEntity,
attachment: Dict[str, Any],
dest_path: Union[Path, str],
extra: Optional[Dict[str, Any]] = None
) -> Tuple[str, defer.Deferred]:
"""Download a file from URI using suitable method
@param uri: URI to the file to download
@param dest_path: where the file must be downloaded
if empty string, the file will be stored in local path
@param extra: options depending on scheme handler
Some common options:
- ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification
used only if HTTPS transport is needed
@return: ``progress_id`` and a Deferred which fire download URL when download is
finished.
``progress_id`` can be empty string if the file already exist and is not
downloaded again (can happen if cache is used with empty ``dest_path``).
"""
uri = attachment.get("uri")
if uri:
return await self.download_uri(client, uri, dest_path, extra)
else:
for source in attachment.get("sources", []):
source_type = source.get("type")
if not source_type:
log.warning(
"source type is missing for source: {source}\nattachment: "
f"{attachment}"
)
continue
try:
cb = self._download_callbacks[source_type]
except KeyError:
log.warning(
f"no source handler registered for {source_type!r}"
)
else:
try:
return await cb(client, attachment, source, dest_path, extra)
except exceptions.CancelError as e:
# the handler can't or doesn't want to handle this source
log.debug(
f"Following source handling by {cb} has been cancelled ({e}):"
f"{source}"
)
log.warning(
"no source could be handled, we can't download the attachment:\n"
f"{attachment}"
)
raise exceptions.FeatureNotFound("no handler could manage the attachment")
def register_download_handler(
self,
source_type: str,
callback: Callable[
[
SatXMPPEntity, Dict[str, Any], Dict[str, Any], Union[str, Path],
Dict[str, Any]
],
Tuple[str, defer.Deferred]
]
) -> None:
"""Register a handler to manage a type of attachment source
@param source_type: ``type`` of source handled
This is usually the namespace of the protocol used
@param callback: method to call to manage the source.
Call arguments are the same as for [download], with an extra ``source`` dict
which is used just after ``attachment`` to give a quick reference to the
source used.
The callabke must return a tuple with:
- progress ID
- a Deferred which fire whant the file is fully downloaded
"""
if source_type is self._download_callbacks:
raise exceptions.ConflictError(
f"The is already a callback registered for source type {source_type!r}"
)
self._download_callbacks[source_type] = callback
def register_scheme(self, scheme: str, download_cb: Callable) -> None:
"""Register an URI scheme handler
@param scheme(unicode): URI scheme this callback is handling
@param download_cb(callable): callback to download a file
@param scheme: URI scheme this callback is handling
@param download_cb: callback to download a file
arguments are:
- (SatXMPPClient) client
- (urllib.parse.SplitResult) parsed URI
@ -208,19 +308,19 @@ class DownloadPlugin(object):
must return a tuple with progress_id and a Deferred which fire when download
is finished
"""
if scheme in self._download_callbacks:
if scheme in self._scheme_callbacks:
raise exceptions.ConflictError(
f"A method with scheme {scheme!r} is already registered"
)
self._download_callbacks[scheme] = download_cb
self._scheme_callbacks[scheme] = download_cb
def unregister(self, scheme):
try:
del self._download_callbacks[scheme]
del self._scheme_callbacks[scheme]
except KeyError:
raise exceptions.NotFound(f"No callback registered for scheme {scheme!r}")
def errbackDownload(self, file_obj, download_d, resp):
def errback_download(self, file_obj, download_d, resp):
"""Set file_obj and download deferred appropriatly after a network error
@param file_obj(SatFile): file where the download must be done
@ -231,7 +331,7 @@ class DownloadPlugin(object):
file_obj.close(error=msg)
download_d.errback(exceptions.NetworkError(msg))
async def downloadHTTP(self, client, uri_parsed, dest_path, options):
async def download_http(self, client, uri_parsed, dest_path, options):
url = uri_parsed.geturl()
if options.get('ignore_tls_errors', False):
@ -264,5 +364,5 @@ class DownloadPlugin(object):
d.addBoth(lambda _: file_obj.close())
else:
d = defer.Deferred()
self.errbackDownload(file_obj, d, resp)
self.errback_download(file_obj, d, resp)
return progress_id, d

View file

@ -18,15 +18,20 @@
import os
import os.path
from pathlib import Path
from typing import Optional, Tuple, Union
from twisted.internet import defer
from twisted.words.protocols.jabber import jid
from twisted.words.protocols.jabber import error as jabber_error
from sat.core.i18n import _, D_
from sat.core.constants import Const as C
from sat.tools.common import data_format
from sat.core.log import getLogger
from sat.core import exceptions
from sat.core.constants import Const as C
from sat.core.core_types import SatXMPPEntity
from sat.core.i18n import D_, _
from sat.core.log import getLogger
from sat.tools import xml_tools
from sat.tools.common import data_format
log = getLogger(__name__)
@ -99,26 +104,32 @@ class UploadPlugin(object):
else:
return {"progress": progress_id}
async def upload(self, client, filepath, filename=None, upload_jid=None,
options=None):
async def upload(
self,
client: SatXMPPEntity,
filepath: Union[Path, str],
filename: Optional[str] = None,
upload_jid: Optional[jid.JID] = None,
extra: Optional[dict]=None
) -> Tuple[str, defer.Deferred]:
"""Send a file using best available method
@param filepath(str): absolute path to the file
@param filename(None, unicode): name to use for the upload
@param filepath: absolute path to the file
@param filename: name to use for the upload
None to use basename of the path
@param upload_jid(jid.JID, None): upload capable entity jid,
@param upload_jid: upload capable entity jid,
or None to use autodetected, if possible
@param options(dict): option to use for the upload, may be:
@param extra: extra data/options to use for the upload, may be:
- ignore_tls_errors(bool): True to ignore SSL/TLS certificate verification
used only if HTTPS transport is needed
- progress_id(str): id to use for progression
if not specified, one will be generated
@param profile: %(doc_profile)s
@return (tuple[unicode,D(unicode)]): progress_id and a Deferred which fire
download URL when upload is finished
@return: progress_id and a Deferred which fire download URL when upload is
finished
"""
if options is None:
options = {}
if extra is None:
extra = {}
if not os.path.isfile(filepath):
raise exceptions.DataError("The given path doesn't link to a file")
for method_name, available_cb, upload_cb, priority in self._upload_callbacks:
@ -132,7 +143,7 @@ class UploadPlugin(object):
"{name} method will be used to upload the file".format(name=method_name)
)
progress_id, download_d = await upload_cb(
client, filepath, filename, upload_jid, options
client, filepath, filename, upload_jid, extra
)
return progress_id, download_d

View file

@ -63,13 +63,13 @@ class AESGCM(object):
log.info(_("AESGCM plugin initialization"))
self._http_upload = host.plugins['XEP-0363']
self._attach = host.plugins["ATTACH"]
host.plugins["DOWNLOAD"].registerScheme(
host.plugins["DOWNLOAD"].register_scheme(
"aesgcm", self.download
)
self._attach.register(
self.canHandleAttachment, self.attach, encrypted=True)
host.trigger.add("XEP-0363_upload_size", self._uploadSizeTrigger)
host.trigger.add("XEP-0363_upload", self._uploadTrigger)
host.trigger.add("XEP-0363_upload_pre_slot", self._upload_pre_slot)
host.trigger.add("XEP-0363_upload", self._upload_trigger)
host.trigger.add("messageReceived", self._messageReceivedTrigger)
async def download(self, client, uri_parsed, dest_path, options):
@ -129,7 +129,7 @@ class AESGCM(object):
decryptor=decryptor))
else:
d = defer.Deferred()
self.host.plugins["DOWNLOAD"].errbackDownload(file_obj, d, resp)
self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
return progress_id, d
async def canHandleAttachment(self, client, data):
@ -140,13 +140,13 @@ class AESGCM(object):
else:
return True
async def _uploadCb(self, client, filepath, filename, options):
options['encryption'] = C.ENC_AES_GCM
return await self._http_upload.fileHTTPUpload(
async def _upload_cb(self, client, filepath, filename, extra):
extra['encryption'] = C.ENC_AES_GCM
return await self._http_upload.file_http_upload(
client=client,
filepath=filepath,
filename=filename,
options=options
extra=extra
)
async def attach(self, client, data):
@ -160,14 +160,16 @@ class AESGCM(object):
if not data['message'] or data['message'] == {'': ''}:
extra_attachments = attachments[1:]
del attachments[1:]
await self._attach.uploadFiles(client, data, upload_cb=self._uploadCb)
await self._attach.upload_files(client, data, upload_cb=self._upload_cb)
else:
# we have a message, we must send first attachment separately
extra_attachments = attachments[:]
attachments.clear()
del data["extra"][C.MESS_KEY_ATTACHMENTS]
body_elt = next(data["xml"].elements(C.NS_CLIENT, "body"))
body_elt = data["xml"].body
if body_elt is None:
body_elt = data["xml"].addElement("body")
for attachment in attachments:
body_elt.addContent(attachment["url"])
@ -219,11 +221,11 @@ class AESGCM(object):
decrypted = decryptor.update(data)
file_obj.write(decrypted)
def _uploadSizeTrigger(self, client, options, file_path, size, size_adjust):
if options.get('encryption') != C.ENC_AES_GCM:
def _upload_pre_slot(self, client, extra, file_metadata):
if extra.get('encryption') != C.ENC_AES_GCM:
return True
# the tag is appended to the file
size_adjust.append(16)
file_metadata["size"] += 16
return True
def _encrypt(self, data, encryptor):
@ -239,8 +241,8 @@ class AESGCM(object):
# as we have already finalized, we can now send EOF
return b''
def _uploadTrigger(self, client, options, sat_file, file_producer, slot):
if options.get('encryption') != C.ENC_AES_GCM:
def _upload_trigger(self, client, extra, sat_file, file_producer, slot):
if extra.get('encryption') != C.ENC_AES_GCM:
return True
log.debug("encrypting file with AES-GCM")
iv = secrets.token_bytes(12)
@ -255,7 +257,7 @@ class AESGCM(object):
# so we need to check with final data length to avoid a warning on close()
sat_file.check_size_with_read = True
# file_producer get length directly from file, and this cause trouble has
# file_producer get length directly from file, and this cause trouble as
# we have to change the size because of encryption. So we adapt it here,
# else the producer would stop reading prematurely
file_producer.length = sat_file.size

View file

@ -16,25 +16,29 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
import os.path
import mimetypes
from typing import NamedTuple, Callable, Optional
from dataclasses import dataclass
import mimetypes
import os.path
from pathlib import Path
from typing import Callable, NamedTuple, Optional, Tuple
from urllib import parse
from wokkel import disco, iwokkel
from zope.interface import implementer
from twisted.words.protocols.jabber import jid, xmlstream, error
from twisted.words.xish import domish
from twisted.internet import reactor
from twisted.internet import defer
from twisted.web import client as http_client
from twisted.web import http_headers
from sat.core.i18n import _
from sat.core.xmpp import SatXMPPComponent
from sat.core.constants import Const as C
from sat.core.log import getLogger
from twisted.words.protocols.jabber import error, jid, xmlstream
from twisted.words.xish import domish
from wokkel import disco, iwokkel
from zope.interface import implementer
from sat.core import exceptions
from sat.tools import web as sat_web, utils
from sat.core.constants import Const as C
from sat.core.core_types import SatXMPPEntity
from sat.core.i18n import _
from sat.core.log import getLogger
from sat.core.xmpp import SatXMPPComponent
from sat.tools import utils, web as sat_web
log = getLogger(__name__)
@ -87,7 +91,7 @@ class XEP_0363:
".plugin",
in_sign="sssbs",
out_sign="",
method=self._fileHTTPUpload,
method=self._file_http_upload,
)
host.bridge.addMethod(
"fileHTTPUploadGetSlot",
@ -98,7 +102,7 @@ class XEP_0363:
async_=True,
)
host.plugins["UPLOAD"].register(
"HTTP Upload", self.getHTTPUploadEntity, self.fileHTTPUpload
"HTTP Upload", self.getHTTPUploadEntity, self.file_http_upload
)
# list of callbacks used when a request is done to a component
self.handlers = []
@ -151,58 +155,67 @@ class XEP_0363:
return entity
def _fileHTTPUpload(self, filepath, filename="", upload_jid="",
def _file_http_upload(self, filepath, filename="", upload_jid="",
ignore_tls_errors=False, profile=C.PROF_KEY_NONE):
assert os.path.isabs(filepath) and os.path.isfile(filepath)
client = self.host.getClient(profile)
progress_id_d, __ = defer.ensureDeferred(self.fileHTTPUpload(
return defer.ensureDeferred(self.file_http_upload(
client,
filepath,
filename or None,
jid.JID(upload_jid) if upload_jid else None,
{"ignore_tls_errors": ignore_tls_errors},
))
return progress_id_d
async def fileHTTPUpload(
self, client, filepath, filename=None, upload_jid=None, options=None):
async def file_http_upload(
self,
client: SatXMPPEntity,
filepath: Path,
filename: Optional[str] = None,
upload_jid: Optional[jid.JID] = None,
extra: Optional[dict] = None
) -> Tuple[str, defer.Deferred]:
"""Upload a file through HTTP
@param filepath(str): absolute path of the file
@param filename(None, unicode): name to use for the upload
@param filepath: absolute path of the file
@param filename: name to use for the upload
None to use basename of the path
@param upload_jid(jid.JID, None): upload capable entity jid,
@param upload_jid: upload capable entity jid,
or None to use autodetected, if possible
@param options(dict): options where key can be:
@param extra: options where key can be:
- ignore_tls_errors(bool): if True, SSL certificate will not be checked
- attachment(dict): file attachment data
@param profile: %(doc_profile)s
@return (D(tuple[D(unicode), D(unicode)])): progress id and Deferred which fire
download URL
@return: progress id and Deferred which fire download URL
"""
if options is None:
options = {}
ignore_tls_errors = options.get("ignore_tls_errors", False)
filename = filename or os.path.basename(filepath)
size = os.path.getsize(filepath)
if extra is None:
extra = {}
ignore_tls_errors = extra.get("ignore_tls_errors", False)
file_metadata = {
"filename": filename or os.path.basename(filepath),
"filepath": filepath,
"size": os.path.getsize(filepath),
}
size_adjust = []
#: this trigger can be used to modify the requested size, it is notably useful
#: with encryption. The size_adjust is a list which can be filled by int to add
#: to the initial size
#: this trigger can be used to modify the filename or size requested when geting
#: the slot, it is notably useful with encryption.
self.host.trigger.point(
"XEP-0363_upload_size", client, options, filepath, size, size_adjust,
triggers_no_cancel=True)
if size_adjust:
size = sum([size, *size_adjust])
"XEP-0363_upload_pre_slot", client, extra, file_metadata,
triggers_no_cancel=True
)
try:
slot = await self.getSlot(client, filename, size, upload_jid=upload_jid)
slot = await self.getSlot(
client, file_metadata["filename"], file_metadata["size"],
upload_jid=upload_jid
)
except Exception as e:
log.warning(_("Can't get upload slot: {reason}").format(reason=e))
raise e
else:
log.debug(f"Got upload slot: {slot}")
sat_file = self.host.plugins["FILE"].File(
self.host, client, filepath, uid=options.get("progress_id"), size=size,
self.host, client, filepath, uid=extra.get("progress_id"),
size=file_metadata["size"],
auto_end_signals=False
)
progress_id = sat_file.uid
@ -223,7 +236,7 @@ class XEP_0363:
await self.host.trigger.asyncPoint(
"XEP-0363_upload", client, options, sat_file, file_producer, slot,
"XEP-0363_upload", client, extra, sat_file, file_producer, slot,
triggers_no_cancel=True)
download_d = agent.request(
@ -233,8 +246,8 @@ class XEP_0363:
file_producer,
)
download_d.addCallbacks(
self._uploadCb,
self._uploadEb,
self._upload_cb,
self._upload_eb,
(sat_file, slot),
None,
(sat_file,),
@ -242,7 +255,7 @@ class XEP_0363:
return progress_id, download_d
def _uploadCb(self, __, sat_file, slot):
def _upload_cb(self, __, sat_file, slot):
"""Called once file is successfully uploaded
@param sat_file(SatFile): file used for the upload
@ -253,7 +266,7 @@ class XEP_0363:
sat_file.progressFinished({"url": slot.get})
return slot.get
def _uploadEb(self, failure_, sat_file):
def _upload_eb(self, failure_, sat_file):
"""Called on unsuccessful upload
@param sat_file(SatFile): file used for the upload

View file

@ -107,19 +107,19 @@ class XEP_0446:
self,
file_metadata_elt: domish.Element
) -> Dict[str, Any]:
"""Parse <file-metadata/> element
"""Parse <file/> element
@param file_metadata_elt: <file-metadata/> element
@param file_metadata_elt: <file/> element
a parent element can also be used
@return: file-metadata data. It's a dict whose keys correspond to
@return: file metadata. It's a dict whose keys correspond to
[get_file_metadata_elt] parameters
@raise exceptions.NotFound: no <file-metadata/> element has been found
@raise exceptions.NotFound: no <file/> element has been found
"""
if file_metadata_elt.name != "file-metadata":
if file_metadata_elt.name != "file":
try:
file_metadata_elt = next(
file_metadata_elt.elements(NS_FILE_METADATA, "file-metadata")
file_metadata_elt.elements(NS_FILE_METADATA, "file")
)
except StopIteration:
raise exceptions.NotFound
@ -158,7 +158,7 @@ class XEP_0446:
from sat.tools.xml_tools import pFmtElt
log.warning("invalid <hash/> element:\n{pFmtElt(file_metadata_elt)}")
else:
data["file_hash"] = (algo, hash_.decode())
data["file_hash"] = (algo, hash_)
# TODO: thumbnails

View file

@ -15,14 +15,23 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from typing import Optional, Dict, List, Tuple, Union, Any
from collections import namedtuple
from functools import partial
import mimetypes
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple, Union
import treq
from twisted.internet import defer
from twisted.words.xish import domish
from sat.core import exceptions
from sat.core.constants import Const as C
from sat.core.core_types import SatXMPPEntity
from sat.core.i18n import _
from sat.core.log import getLogger
from sat.core import exceptions
from sat.tools import stream
from sat.tools.web import treq_client_no_ssl
log = getLogger(__name__)
@ -33,23 +42,130 @@ PLUGIN_INFO = {
C.PI_TYPE: "XEP",
C.PI_MODES: C.PLUG_MODE_BOTH,
C.PI_PROTOCOLS: ["XEP-0447"],
C.PI_DEPENDENCIES: ["XEP-0103", "XEP-0446"],
C.PI_DEPENDENCIES: ["XEP-0103", "XEP-0334", "XEP-0446", "ATTACH", "DOWNLOAD"],
C.PI_RECOMMENDATIONS: ["XEP-0363"],
C.PI_MAIN: "XEP_0447",
C.PI_HANDLER: "no",
C.PI_DESCRIPTION: _("""Implementation of XEP-0447 (Stateless File Sharing)"""),
}
NS_SFS = "urn:xmpp:sfs:0"
SourceHandler = namedtuple("SourceHandler", ["callback", "encrypted"])
class XEP_0447:
namespace = NS_SFS
def __init__(self, host):
self.host = host
log.info(_("XEP-0447 (Stateless File Sharing) plugin initialization"))
host.registerNamespace("sfs", NS_SFS)
self._sources_handlers = {}
self._u = host.plugins["XEP-0103"]
self._hints = host.plugins["XEP-0334"]
self._m = host.plugins["XEP-0446"]
self._http_upload = host.plugins.get("XEP-0363")
self._attach = host.plugins["ATTACH"]
self._attach.register(
self.can_handle_attachment, self.attach, priority=1000
)
self.register_source_handler(
self._u.namespace, "url-data", self._u.parse_url_data_elt
)
host.plugins["DOWNLOAD"].register_download_handler(self._u.namespace, self.download)
host.trigger.add("messageReceived", self._message_received_trigger)
def register_source_handler(
self, namespace: str, element_name: str,
callback: Callable[[domish.Element], Dict[str, Any]],
encrypted: bool = False
) -> None:
"""Register a handler for file source
@param namespace: namespace of the element supported
@param element_name: name of the element supported
@param callback: method to call to parse the element
get the matching element as argument, must return the parsed data
@param encrypted: if True, the source is encrypted (the transmitting channel
should then be end2end encrypted to avoir leaking decrypting data to servers).
"""
key = (namespace, element_name)
if key in self._sources_handlers:
raise exceptions.ConflictError(
f"There is already a resource handler for namespace {namespace!r} and "
f"name {element_name!r}"
)
self._sources_handlers[key] = SourceHandler(callback, encrypted)
async def download(
self,
client: SatXMPPEntity,
attachment: Dict[str, Any],
source: Dict[str, Any],
dest_path: Union[Path, str],
extra: Optional[Dict[str, Any]] = None
) -> Tuple[str, defer.Deferred]:
# TODO: handle url-data headers
if extra is None:
extra = {}
try:
download_url = source["url"]
except KeyError:
raise ValueError(f"{source} has missing URL")
if extra.get('ignore_tls_errors', False):
log.warning(
"TLS certificate check disabled, this is highly insecure"
)
treq_client = treq_client_no_ssl
else:
treq_client = treq
try:
file_size = int(attachment["size"])
except (KeyError, ValueError):
head_data = await treq_client.head(download_url)
file_size = int(head_data.headers.getRawHeaders('content-length')[0])
file_obj = stream.SatFile(
self.host,
client,
dest_path,
mode="wb",
size = file_size,
)
progress_id = file_obj.uid
resp = await treq_client.get(download_url, unbuffered=True)
if resp.code == 200:
d = treq.collect(resp, file_obj.write)
d.addCallback(lambda __: file_obj.close())
else:
d = defer.Deferred()
self.host.plugins["DOWNLOAD"].errback_download(file_obj, d, resp)
return progress_id, d
async def can_handle_attachment(self, client, data):
if self._http_upload is None:
return False
try:
await self._http_upload.getHTTPUploadEntity(client)
except exceptions.NotFound:
return False
else:
return True
def get_sources_elt(
self,
children: Optional[List[domish.Element]] = None
) -> domish.Element:
"""Generate <sources> element"""
sources_elt = domish.Element((NS_SFS, "sources"))
if children:
for child in children:
sources_elt.addChild(child)
return sources_elt
def get_file_sharing_elt(
self,
@ -75,6 +191,8 @@ class XEP_0447:
file_sharing_elt = domish.Element((NS_SFS, "file-sharing"))
if disposition is not None:
file_sharing_elt["disposition"] = disposition
if media_type is None and name:
media_type = mimetypes.guess_type(name, strict=False)[0]
file_sharing_elt.addChild(
self._m.get_file_metadata_elt(
name=name,
@ -89,7 +207,8 @@ class XEP_0447:
thumbnail=thumbnail,
)
)
sources_elt = file_sharing_elt.addElement("sources")
sources_elt = self.get_sources_elt()
file_sharing_elt.addChild(sources_elt)
for source_data in sources:
if "url" in source_data:
sources_elt.addChild(
@ -102,6 +221,42 @@ class XEP_0447:
return file_sharing_elt
def parse_sources_elt(
self,
sources_elt: domish.Element
) -> List[Dict[str, Any]]:
"""Parse <sources/> element
@param sources_elt: <sources/> element, or a direct parent element
@return: list of found sources data
@raise: exceptions.NotFound: Can't find <sources/> element
"""
if sources_elt.name != "sources" or sources_elt.uri != NS_SFS:
try:
sources_elt = next(sources_elt.elements(NS_SFS, "sources"))
except StopIteration:
raise exceptions.NotFound(
f"<sources/> element is missing: {sources_elt.toXml()}")
sources = []
for elt in sources_elt.elements():
if not elt.uri:
log.warning("ignoring source element {elt.toXml()}")
continue
key = (elt.uri, elt.name)
try:
source_handler = self._sources_handlers[key]
except KeyError:
log.warning(f"unmanaged file sharing element: {elt.toXml}")
continue
else:
source_data = source_handler.callback(elt)
if source_handler.encrypted:
source_data[C.MESS_KEY_ENCRYPTED] = True
if "type" not in source_data:
source_data["type"] = elt.uri
sources.append(source_data)
return sources
def parse_file_sharing_elt(
self,
file_sharing_elt: domish.Element
@ -126,17 +281,95 @@ class XEP_0447:
disposition = file_sharing_elt.getAttribute("disposition")
if disposition is not None:
data["disposition"] = disposition
sources = data["sources"] = []
try:
sources_elt = next(file_sharing_elt.elements(NS_SFS, "sources"))
except StopIteration:
raise ValueError(f"<sources/> element is missing: {file_sharing_elt.toXml()}")
for elt in sources_elt.elements():
if elt.name == "url-data" and elt.uri == self._u.namespace:
source_data = self._u.parse_url_data_elt(elt)
else:
log.warning(f"unmanaged file sharing element: {elt.toXml}")
continue
sources.append(source_data)
data["sources"] = self.parse_sources_elt(file_sharing_elt)
except exceptions.NotFound as e:
raise ValueError(str(e))
return data
def _add_file_sharing_attachments(
self,
client: SatXMPPEntity,
message_elt: domish.Element,
data: Dict[str, Any]
) -> Dict[str, Any]:
"""Check <message> for a shared file, and add it as an attachment"""
# XXX: XEP-0447 doesn't support several attachments in a single message, thus only
# one attachment can be added
try:
attachment = self.parse_file_sharing_elt(message_elt)
except exceptions.NotFound:
return data
if any(
s.get(C.MESS_KEY_ENCRYPTED, False)
for s in attachment["sources"]
) and client.encryption.isEncrypted(data):
# we don't add the encrypted flag if the message itself is not encrypted,
# because the decryption key is part of the link, so sending it over
# unencrypted channel is like having no encryption at all.
attachment[C.MESS_KEY_ENCRYPTED] = True
attachments = data['extra'].setdefault(C.MESS_KEY_ATTACHMENTS, [])
attachments.append(attachment)
return data
async def attach(self, client, data):
# XXX: for now, XEP-0447 only allow to send one file per <message/>, thus we need
# to send each file in a separate message
attachments = data["extra"][C.MESS_KEY_ATTACHMENTS]
if not data['message'] or data['message'] == {'': ''}:
extra_attachments = attachments[1:]
del attachments[1:]
else:
# we have a message, we must send first attachment separately
extra_attachments = attachments[:]
attachments.clear()
del data["extra"][C.MESS_KEY_ATTACHMENTS]
if attachments:
if len(attachments) > 1:
raise exceptions.InternalError(
"There should not be more that one attachment at this point"
)
await self._attach.upload_files(client, data)
self._hints.addHintElements(data["xml"], [self._hints.HINT_STORE])
for attachment in attachments:
try:
file_hash = (attachment["hash_algo"], attachment["hash"])
except KeyError:
file_hash = None
file_sharing_elt = self.get_file_sharing_elt(
[{"url": attachment["url"]}],
name=attachment["name"],
size=attachment["size"],
file_hash=file_hash
)
data["xml"].addChild(file_sharing_elt)
for attachment in extra_attachments:
# we send all remaining attachment in a separate message
await client.sendMessage(
to_jid=data['to'],
message={'': ''},
subject=data['subject'],
mess_type=data['type'],
extra={C.MESS_KEY_ATTACHMENTS: [attachment]},
)
if ((not data['extra']
and (not data['message'] or data['message'] == {'': ''})
and not data['subject'])):
# nothing left to send, we can cancel the message
raise exceptions.CancelError("Cancelled by XEP_0447 attachment handling")
def _message_received_trigger(self, client, message_elt, post_treat):
# we use a post_treat callback instead of "message_parse" trigger because we need
# to check if the "encrypted" flag is set to decide if we add the same flag to the
# attachment
post_treat.addCallback(
partial(self._add_file_sharing_attachments, client, message_elt)
)
return True