diff --git a/sat/plugins/plugin_misc_attach.py b/sat/plugins/plugin_misc_attach.py index 7512bfc4..4050d8fc 100644 --- a/sat/plugins/plugin_misc_attach.py +++ b/sat/plugins/plugin_misc_attach.py @@ -18,10 +18,14 @@ from functools import partial from pathlib import Path +from collections import namedtuple from twisted.internet import defer from sat.core.i18n import _ +from sat.core import exceptions from sat.core.constants import Const as C from sat.core.log import getLogger +from sat.tools import utils + log = getLogger(__name__) @@ -37,6 +41,9 @@ PLUGIN_INFO = { } +AttachmentHandler = namedtuple('AttachmentHandler', ['can_handle', 'attach', 'priority']) + + class AttachPlugin: def __init__(self, host): @@ -44,15 +51,81 @@ class AttachPlugin: self.host = host self._u = host.plugins["UPLOAD"] host.trigger.add("sendMessage", self._sendMessageTrigger) + self._attachments_handlers = {'clear': [], 'encrypted': []} + self.register(self.defaultCanHandle, self.defaultAttach, False, -1000) + + def register(self, can_handle, attach, encrypted=False, priority=0): + """Register an attachments handler + + @param can_handle(callable, coroutine, Deferred): a method which must return True + if this plugin can handle the upload, otherwise next ones will be tried. + This method will get client and mess_data as arguments, before the XML is + generated + @param attach(callable, coroutine, Deferred): attach the file + this method will get client and mess_data as arguments, after XML is + generated. Upload operation must be handled + hint: "UPLOAD" plugin can be used + @param encrypted(bool): True if the handler manages encrypted files + A handler can be registered twice if it handle both encrypted and clear + attachments + @param priority(int): priority of this handler, handler with higher priority will + be tried first + """ + handler = AttachmentHandler(can_handle, attach, priority) + handlers = ( + self._attachments_handlers['encrypted'] + if encrypted else self._attachments_handlers['clear'] + ) + if handler in handlers: + raise exceptions.InternalError( + 'Attachment handler has been registered twice, this should never happen' + ) + + handlers.append(handler) + handlers.sort(key=lambda h: h.priority, reverse=True) + log.debug(f"new attachments handler: {handler}") + + async def attachFiles(self, client, data): + if client.encryption.isEncryptionRequested(data): + handlers = self._attachments_handlers['encrypted'] + else: + handlers = self._attachments_handlers['clear'] + + for handler in handlers: + can_handle = await utils.asDeferred(handler.can_handle, client, data) + if can_handle: + break + else: + raise exceptions.NotFound( + _("No plugin can handle attachment with {destinee}").format( + destinee = data['to'] + )) + + await utils.asDeferred(handler.attach, client, data) - def _attachFiles(self, client, data): - # TODO: handle xhtml-im - body_elt = next(data["xml"].elements((C.NS_CLIENT, "body"))) - for attachment in data["extra"][C.MESS_KEY_ATTACHMENTS]: - body_elt.addContent(f'\n{attachment["url"]}') return data - async def uploadFiles(self, client, data): + async def uploadFiles(self, client, data, upload_cb=None): + """Upload file, and update attachments + + invalid attachments will be removed + @param client: + @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: + - client + - filepath + - filename + - options + the method must return a tuple similar to UPLOAD plugin's upload method, + it must contain: + - progress_id + - a deferred which fire download URL + """ + if upload_cb is None: + upload_cb = self._u.upload + uploads_d = [] to_delete = [] attachments = data["extra"]["attachments"] @@ -79,23 +152,18 @@ class AttachPlugin: name = attachment["name"] = path.name options = {} - progress_id = attachment.get("progress_id") + progress_id = attachment.pop("progress_id", None) if progress_id: - options["progress_id"] = attachment["progress_id"] + options["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 log.warning( _("certificate check disabled for upload, this is dangerous!")) - if client.encryption.isEncryptionRequested(data): - # FIXME: we should not use implementation specific value here - # but for now it's the only file encryption method available with - # with upload. - options['encryption'] = C.ENC_AES_GCM - __, upload_d = await self._u.upload( - client, + __, upload_d = await upload_cb( + client=client, filepath=path, filename=name, options=options, @@ -118,12 +186,26 @@ class AttachPlugin: return data - def _uploadFiles(self, client, data): - return defer.ensureDeferred(self.uploadFiles(client, data)) + def _attachFiles(self, client, data): + return defer.ensureDeferred(self.attachFiles(client, data)) def _sendMessageTrigger( self, client, mess_data, pre_xml_treatments, post_xml_treatments): if mess_data['extra'].get(C.MESS_KEY_ATTACHMENTS): - pre_xml_treatments.addCallback(partial(self._uploadFiles, client)) post_xml_treatments.addCallback(partial(self._attachFiles, client)) return True + + async def defaultCanHandle(self, client, data): + return True + + async def defaultAttach(self, client, data): + await self.uploadFiles(client, data) + # TODO: handle xhtml-im + body_elt = next(data["xml"].elements((C.NS_CLIENT, "body"))) + attachments = data["extra"][C.MESS_KEY_ATTACHMENTS] + if attachments: + body_links = '\n'.join(a['url'] for a in attachments) + if str(body_elt).strip(): + # if there is already a body, we add a line feed before the first link + body_elt.addContent('\n') + body_elt.addContent(body_links) diff --git a/sat/plugins/plugin_sec_aesgcm.py b/sat/plugins/plugin_sec_aesgcm.py index b3cf98b8..773c2c37 100644 --- a/sat/plugins/plugin_sec_aesgcm.py +++ b/sat/plugins/plugin_sec_aesgcm.py @@ -42,7 +42,7 @@ PLUGIN_INFO = { C.PI_IMPORT_NAME: "AES-GCM", C.PI_TYPE: "SEC", C.PI_PROTOCOLS: ["OMEMO Media sharing"], - C.PI_DEPENDENCIES: ["XEP-0363", "XEP-0384", "DOWNLOAD"], + C.PI_DEPENDENCIES: ["XEP-0363", "XEP-0384", "DOWNLOAD", "ATTACH"], C.PI_MAIN: "AESGCM", C.PI_HANDLER: "no", C.PI_DESCRIPTION: dedent(_("""\ @@ -61,9 +61,13 @@ class AESGCM(object): def __init__(self, host): self.host = host log.info(_("AESGCM plugin initialization")) + self._http_upload = host.plugins['XEP-0363'] + self._attach = host.plugins["ATTACH"] host.plugins["DOWNLOAD"].registerScheme( "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("messageReceived", self._messageReceivedTrigger) @@ -128,6 +132,62 @@ class AESGCM(object): self.host.plugins["DOWNLOAD"].errbackDownload(file_obj, d, resp) return progress_id, d + async def canHandleAttachment(self, client, data): + try: + await self._http_upload.getHTTPUploadEntity(client) + except exceptions.NotFound: + return False + else: + return True + + async def _uploadCb(self, client, filepath, filename, options): + options['encryption'] = C.ENC_AES_GCM + return await self._http_upload.fileHTTPUpload( + client=client, + filepath=filepath, + filename=filename, + options=options + ) + + async def attach(self, client, data): + # XXX: the attachment removal/resend code below is due to the one file per + # message limitation of OMEMO media sharing unofficial XEP. We have to remove + # attachments from original message, and send them one by one. + # TODO: this is to be removed when a better mechanism is available with OMEMO (now + # possible with the 0.4 version of OMEMO, it's possible to encrypt other stanza + # elements than body). + attachments = data["extra"][C.MESS_KEY_ATTACHMENTS] + if not data['message'] or data['message'] == {'': ''}: + extra_attachments = attachments[1:] + del attachments[1:] + await self._attach.uploadFiles(client, data, upload_cb=self._uploadCb) + 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"))) + + for attachment in attachments: + body_elt.addContent(attachment["url"]) + + for attachment in extra_attachments: + # we send all remaining attachment in a separate message + 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 AESGCM attachment handling") + def onDataDownload(self, data, client, file_obj, decryptor): if file_obj.tell() + len(data) > file_obj.size: # we're reaching end of file with this bunch of data diff --git a/sat/plugins/plugin_xep_0363.py b/sat/plugins/plugin_xep_0363.py index 1e2a5ae0..618f9d3e 100644 --- a/sat/plugins/plugin_xep_0363.py +++ b/sat/plugins/plugin_xep_0363.py @@ -141,7 +141,6 @@ class XEP_0363(object): filename = filename or os.path.basename(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