comparison libervia/backend/plugins/plugin_misc_attach.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_misc_attach.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # SàT plugin for attaching files
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 collections import namedtuple
20 import mimetypes
21 from pathlib import Path
22 import shutil
23 import tempfile
24 from typing import Callable, Optional
25
26 from twisted.internet import defer
27
28 from libervia.backend.core import exceptions
29 from libervia.backend.core.constants import Const as C
30 from libervia.backend.core.core_types import SatXMPPEntity
31 from libervia.backend.core.i18n import _
32 from libervia.backend.core.log import getLogger
33 from libervia.backend.tools import utils
34 from libervia.backend.tools import image
35
36
37 log = getLogger(__name__)
38
39
40 PLUGIN_INFO = {
41 C.PI_NAME: "File Attach",
42 C.PI_IMPORT_NAME: "ATTACH",
43 C.PI_TYPE: C.PLUG_TYPE_MISC,
44 C.PI_MODES: C.PLUG_MODE_BOTH,
45 C.PI_DEPENDENCIES: ["UPLOAD"],
46 C.PI_MAIN: "AttachPlugin",
47 C.PI_HANDLER: "no",
48 C.PI_DESCRIPTION: _("""Attachments handler"""),
49 }
50
51
52 AttachmentHandler = namedtuple('AttachmentHandler', ['can_handle', 'attach', 'priority'])
53
54
55 class AttachPlugin:
56
57 def __init__(self, host):
58 log.info(_("plugin Attach initialization"))
59 self.host = host
60 self._u = host.plugins["UPLOAD"]
61 host.trigger.add("sendMessage", self._send_message_trigger)
62 host.trigger.add("sendMessageComponent", self._send_message_trigger)
63 self._attachments_handlers = {'clear': [], 'encrypted': []}
64 self.register(self.default_can_handle, self.default_attach, False, -1000)
65
66 def register(self, can_handle, attach, encrypted=False, priority=0):
67 """Register an attachments handler
68
69 @param can_handle(callable, coroutine, Deferred): a method which must return True
70 if this plugin can handle the upload, otherwise next ones will be tried.
71 This method will get client and mess_data as arguments, before the XML is
72 generated
73 @param attach(callable, coroutine, Deferred): attach the file
74 this method will get client and mess_data as arguments, after XML is
75 generated. Upload operation must be handled
76 hint: "UPLOAD" plugin can be used
77 @param encrypted(bool): True if the handler manages encrypted files
78 A handler can be registered twice if it handle both encrypted and clear
79 attachments
80 @param priority(int): priority of this handler, handler with higher priority will
81 be tried first
82 """
83 handler = AttachmentHandler(can_handle, attach, priority)
84 handlers = (
85 self._attachments_handlers['encrypted']
86 if encrypted else self._attachments_handlers['clear']
87 )
88 if handler in handlers:
89 raise exceptions.InternalError(
90 'Attachment handler has been registered twice, this should never happen'
91 )
92
93 handlers.append(handler)
94 handlers.sort(key=lambda h: h.priority, reverse=True)
95 log.debug(f"new attachments handler: {handler}")
96
97 async def attach_files(self, client, data):
98 """Main method to attach file
99
100 It will do generic pre-treatment, and call the suitable attachments handler
101 """
102 # we check attachment for pre-treatment like large image resizing
103 # media_type will be added if missing (and if it can be guessed from path)
104 attachments = data["extra"][C.KEY_ATTACHMENTS]
105 tmp_dirs_to_clean = []
106 for attachment in attachments:
107 if attachment.get(C.KEY_ATTACHMENTS_RESIZE, False):
108 path = Path(attachment["path"])
109 try:
110 media_type = attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE]
111 except KeyError:
112 media_type = mimetypes.guess_type(path, strict=False)[0]
113 if media_type is None:
114 log.warning(
115 _("Can't resize attachment of unknown type: {attachment}")
116 .format(attachment=attachment))
117 continue
118 attachment[C.KEY_ATTACHMENTS_MEDIA_TYPE] = media_type
119
120 main_type = media_type.split('/')[0]
121 if main_type == "image":
122 report = image.check(self.host, path)
123 if report['too_large']:
124 tmp_dir = Path(tempfile.mkdtemp())
125 tmp_dirs_to_clean.append(tmp_dir)
126 new_path = tmp_dir / path.name
127 await image.resize(
128 path, report["recommended_size"], dest=new_path)
129 attachment["path"] = new_path
130 log.info(
131 _("Attachment {path!r} has been resized at {new_path!r}")
132 .format(path=str(path), new_path=str(new_path)))
133 else:
134 log.warning(
135 _("Can't resize attachment of type {main_type!r}: {attachment}")
136 .format(main_type=main_type, attachment=attachment))
137
138 if client.encryption.is_encryption_requested(data):
139 handlers = self._attachments_handlers['encrypted']
140 else:
141 handlers = self._attachments_handlers['clear']
142
143 for handler in handlers:
144 can_handle = await utils.as_deferred(handler.can_handle, client, data)
145 if can_handle:
146 break
147 else:
148 raise exceptions.NotFound(
149 _("No plugin can handle attachment with {destinee}").format(
150 destinee = data['to']
151 ))
152
153 await utils.as_deferred(handler.attach, client, data)
154
155 for dir_path in tmp_dirs_to_clean:
156 log.debug(f"Cleaning temporary directory at {dir_path}")
157 shutil.rmtree(dir_path)
158
159 return data
160
161 async def upload_files(
162 self,
163 client: SatXMPPEntity,
164 data: dict,
165 upload_cb: Optional[Callable] = None
166 ):
167 """Upload file, and update attachments
168
169 invalid attachments will be removed
170 @param client:
171 @param data(dict): message data
172 @param upload_cb(coroutine, Deferred, None): method to use for upload
173 if None, upload method from UPLOAD plugin will be used.
174 Otherwise, following kwargs will be used with the cb:
175 - client
176 - filepath
177 - filename
178 - options
179 the method must return a tuple similar to UPLOAD plugin's upload method,
180 it must contain:
181 - progress_id
182 - a deferred which fire download URL
183 """
184 if upload_cb is None:
185 upload_cb = self._u.upload
186
187 uploads_d = []
188 to_delete = []
189 attachments = data["extra"]["attachments"]
190
191 for attachment in attachments:
192 if "url" in attachment and not "path" in attachment:
193 log.debug(f"attachment is external, we don't upload it: {attachment}")
194 continue
195 try:
196 # we pop path because we don't want it to be stored, as the file can be
197 # only in a temporary location
198 path = Path(attachment.pop("path"))
199 except KeyError:
200 log.warning("no path in attachment: {attachment}")
201 to_delete.append(attachment)
202 continue
203
204 if "url" in attachment:
205 url = attachment.pop('url')
206 log.warning(
207 f"unexpected URL in attachment: {url!r}\nattachment: {attachment}"
208 )
209
210 try:
211 name = attachment["name"]
212 except KeyError:
213 name = attachment["name"] = path.name
214
215 attachment["size"] = path.stat().st_size
216
217 extra = {
218 "attachment": attachment
219 }
220 progress_id = attachment.pop("progress_id", None)
221 if progress_id:
222 extra["progress_id"] = progress_id
223 check_certificate = self.host.memory.param_get_a(
224 "check_certificate", "Connection", profile_key=client.profile)
225 if not check_certificate:
226 extra['ignore_tls_errors'] = True
227 log.warning(
228 _("certificate check disabled for upload, this is dangerous!"))
229
230 __, upload_d = await upload_cb(
231 client=client,
232 filepath=path,
233 filename=name,
234 extra=extra,
235 )
236 uploads_d.append(upload_d)
237
238 for attachment in to_delete:
239 attachments.remove(attachment)
240
241 upload_results = await defer.DeferredList(uploads_d)
242 for idx, (success, ret) in enumerate(upload_results):
243 attachment = attachments[idx]
244
245 if not success:
246 # ret is a failure here
247 log.warning(f"error while uploading {attachment}: {ret}")
248 continue
249
250 attachment["url"] = ret
251
252 return data
253
254 def _attach_files(self, data, client):
255 return defer.ensureDeferred(self.attach_files(client, data))
256
257 def _send_message_trigger(
258 self, client, mess_data, pre_xml_treatments, post_xml_treatments):
259 if mess_data['extra'].get(C.KEY_ATTACHMENTS):
260 post_xml_treatments.addCallback(self._attach_files, client=client)
261 return True
262
263 async def default_can_handle(self, client, data):
264 return True
265
266 async def default_attach(self, client, data):
267 await self.upload_files(client, data)
268 # TODO: handle xhtml-im
269 body_elt = data["xml"].body
270 if body_elt is None:
271 body_elt = data["xml"].addElement("body")
272 attachments = data["extra"][C.KEY_ATTACHMENTS]
273 if attachments:
274 body_links = '\n'.join(a['url'] for a in attachments)
275 if str(body_elt).strip():
276 # if there is already a body, we add a line feed before the first link
277 body_elt.addContent('\n')
278 body_elt.addContent(body_links)