comparison libervia/backend/plugins/plugin_xep_0054.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_0054.py@524856bd7b19
children 0d7bb4df2343
comparison
equal deleted inserted replaced
4070:d10748475025 4071:4b842c1fb686
1 #!/usr/bin/env python3
2
3 # SAT plugin for managing xep-0054
4 # Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org)
5 # Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
6
7 # This program is free software: you can redistribute it and/or modify
8 # it under the terms of the GNU Affero General Public License as published by
9 # the Free Software Foundation, either version 3 of the License, or
10 # (at your option) any later version.
11
12 # This program is distributed in the hope that it will be useful,
13 # but WITHOUT ANY WARRANTY; without even the implied warranty of
14 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 # GNU Affero General Public License for more details.
16
17 # You should have received a copy of the GNU Affero General Public License
18 # along with this program. If not, see <http://www.gnu.org/licenses/>.
19
20 import io
21 from base64 import b64decode, b64encode
22 from hashlib import sha1
23 from pathlib import Path
24 from typing import Optional
25 from zope.interface import implementer
26 from twisted.internet import threads, defer
27 from twisted.words.protocols.jabber import jid, error
28 from twisted.words.xish import domish
29 from twisted.python.failure import Failure
30 from wokkel import disco, iwokkel
31 from libervia.backend.core import exceptions
32 from libervia.backend.core.i18n import _
33 from libervia.backend.core.constants import Const as C
34 from libervia.backend.core.log import getLogger
35 from libervia.backend.core.xmpp import SatXMPPEntity
36 from libervia.backend.memory import persistent
37 from libervia.backend.tools import image
38
39 log = getLogger(__name__)
40
41 try:
42 from PIL import Image
43 except:
44 raise exceptions.MissingModule(
45 "Missing module pillow, please download/install it from https://python-pillow.github.io"
46 )
47
48 from twisted.words.protocols.jabber.xmlstream import XMPPHandler
49
50 IMPORT_NAME = "XEP-0054"
51
52 PLUGIN_INFO = {
53 C.PI_NAME: "XEP 0054 Plugin",
54 C.PI_IMPORT_NAME: IMPORT_NAME,
55 C.PI_TYPE: "XEP",
56 C.PI_MODES: C.PLUG_MODE_BOTH,
57 C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"],
58 C.PI_DEPENDENCIES: ["IDENTITY"],
59 C.PI_RECOMMENDATIONS: [],
60 C.PI_MAIN: "XEP_0054",
61 C.PI_HANDLER: "yes",
62 C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""),
63 }
64
65 IQ_GET = '/iq[@type="get"]'
66 NS_VCARD = "vcard-temp"
67 VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests
68
69 PRESENCE = "/presence"
70 NS_VCARD_UPDATE = "vcard-temp:x:update"
71 VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]'
72
73 HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
74
75
76 class XEP_0054(object):
77
78 def __init__(self, host):
79 log.info(_("Plugin XEP_0054 initialization"))
80 self.host = host
81 self._i = host.plugins['IDENTITY']
82 self._i.register(IMPORT_NAME, 'avatar', self.get_avatar, self.set_avatar)
83 self._i.register(IMPORT_NAME, 'nicknames', self.get_nicknames, self.set_nicknames)
84 host.trigger.add("presence_available", self.presence_available_trigger)
85
86 def get_handler(self, client):
87 return XEP_0054_handler(self)
88
89 def presence_available_trigger(self, presence_elt, client):
90 try:
91 avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()]
92 except KeyError:
93 log.info(
94 _("No avatar in cache for {profile}")
95 .format(profile=client.profile))
96 return True
97 x_elt = domish.Element((NS_VCARD_UPDATE, "x"))
98 x_elt.addElement("photo", content=avatar_hash)
99 presence_elt.addChild(x_elt)
100 return True
101
102 async def profile_connecting(self, client):
103 client._xep_0054_avatar_hashes = persistent.PersistentDict(
104 NS_VCARD, client.profile)
105 await client._xep_0054_avatar_hashes.load()
106
107 def save_photo(self, client, photo_elt, entity):
108 """Parse a <PHOTO> photo_elt and save the picture"""
109 # XXX: this method is launched in a separate thread
110 try:
111 mime_type = str(next(photo_elt.elements(NS_VCARD, "TYPE")))
112 except StopIteration:
113 mime_type = None
114 else:
115 if not mime_type:
116 # MIME type not known, we'll try autodetection below
117 mime_type = None
118 elif mime_type == "image/x-png":
119 # XXX: this old MIME type is still used by some clients
120 mime_type = "image/png"
121
122 try:
123 buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL")))
124 except StopIteration:
125 log.warning("BINVAL element not found")
126 raise Failure(exceptions.NotFound())
127
128 if not buf:
129 log.warning("empty avatar for {jid}".format(jid=entity.full()))
130 raise Failure(exceptions.NotFound())
131
132 log.debug(_("Decoding binary"))
133 decoded = b64decode(buf)
134 del buf
135
136 if mime_type is None:
137 log.debug(
138 f"no media type found specified for {entity}'s avatar, trying to "
139 f"guess")
140
141 try:
142 mime_type = image.guess_type(io.BytesIO(decoded))
143 except IOError as e:
144 log.warning(f"Can't open avatar buffer: {e}")
145
146 if mime_type is None:
147 msg = f"Can't find media type for {entity}'s avatar"
148 log.warning(msg)
149 raise Failure(exceptions.DataError(msg))
150
151 image_hash = sha1(decoded).hexdigest()
152 with self.host.common_cache.cache_data(
153 PLUGIN_INFO["import_name"],
154 image_hash,
155 mime_type,
156 ) as f:
157 f.write(decoded)
158 return image_hash
159
160 async def v_card_2_dict(self, client, vcard_elt, entity_jid):
161 """Convert a VCard_elt to a dict, and save binaries"""
162 log.debug(("parsing vcard_elt"))
163 vcard_dict = {}
164
165 for elem in vcard_elt.elements():
166 if elem.name == "FN":
167 vcard_dict["fullname"] = str(elem)
168 elif elem.name == "NICKNAME":
169 nickname = vcard_dict["nickname"] = str(elem)
170 await self._i.update(
171 client,
172 IMPORT_NAME,
173 "nicknames",
174 [nickname],
175 entity_jid
176 )
177 elif elem.name == "URL":
178 vcard_dict["website"] = str(elem)
179 elif elem.name == "EMAIL":
180 vcard_dict["email"] = str(elem)
181 elif elem.name == "BDAY":
182 vcard_dict["birthday"] = str(elem)
183 elif elem.name == "PHOTO":
184 # TODO: handle EXTVAL
185 try:
186 avatar_hash = await threads.deferToThread(
187 self.save_photo, client, elem, entity_jid
188 )
189 except (exceptions.DataError, exceptions.NotFound):
190 avatar_hash = ""
191 vcard_dict["avatar"] = avatar_hash
192 except Exception as e:
193 log.error(f"avatar saving error: {e}")
194 avatar_hash = None
195 else:
196 vcard_dict["avatar"] = avatar_hash
197 if avatar_hash is not None:
198 await client._xep_0054_avatar_hashes.aset(
199 entity_jid.full(), avatar_hash)
200
201 if avatar_hash:
202 avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
203 await self._i.update(
204 client,
205 IMPORT_NAME,
206 "avatar",
207 {
208 'path': avatar_cache['path'],
209 'filename': avatar_cache['filename'],
210 'media_type': avatar_cache['mime_type'],
211 'cache_uid': avatar_hash
212 },
213 entity_jid
214 )
215 else:
216 await self._i.update(
217 client, IMPORT_NAME, "avatar", None, entity_jid)
218 else:
219 log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name))
220
221 return vcard_dict
222
223 async def get_vcard_element(self, client, entity_jid):
224 """Retrieve domish.Element of a VCard
225
226 @param entity_jid(jid.JID): entity from who we need the vCard
227 @raise DataError: we got an invalid answer
228 """
229 iq_elt = client.IQ("get")
230 iq_elt["from"] = client.jid.full()
231 iq_elt["to"] = entity_jid.full()
232 iq_elt.addElement("vCard", NS_VCARD)
233 iq_ret_elt = await iq_elt.send(entity_jid.full())
234 try:
235 return next(iq_ret_elt.elements(NS_VCARD, "vCard"))
236 except StopIteration:
237 log.warning(_(
238 "vCard element not found for {entity_jid}: {xml}"
239 ).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml()))
240 raise exceptions.DataError(f"no vCard element found for {entity_jid}")
241
242 async def update_vcard_elt(self, client, entity_jid, to_replace):
243 """Create a vcard element to replace some metadata
244
245 @param to_replace(list[str]): list of vcard element names to remove
246 """
247 try:
248 # we first check if a vcard already exists, to keep data
249 vcard_elt = await self.get_vcard_element(client, entity_jid)
250 except error.StanzaError as e:
251 if e.condition == "item-not-found":
252 vcard_elt = domish.Element((NS_VCARD, "vCard"))
253 else:
254 raise e
255 except exceptions.DataError:
256 vcard_elt = domish.Element((NS_VCARD, "vCard"))
257 else:
258 # the vcard exists, we need to remove elements that we'll replace
259 for elt_name in to_replace:
260 try:
261 elt = next(vcard_elt.elements(NS_VCARD, elt_name))
262 except StopIteration:
263 pass
264 else:
265 vcard_elt.children.remove(elt)
266
267 return vcard_elt
268
269 async def get_card(self, client, entity_jid):
270 """Ask server for VCard
271
272 @param entity_jid(jid.JID): jid from which we want the VCard
273 @result(dict): vCard data
274 """
275 entity_jid = self._i.get_identity_jid(client, entity_jid)
276 log.debug(f"Asking for {entity_jid}'s VCard")
277 try:
278 vcard_elt = await self.get_vcard_element(client, entity_jid)
279 except exceptions.DataError:
280 self._i.update(client, IMPORT_NAME, "avatar", None, entity_jid)
281 except Exception as e:
282 log.warning(_(
283 "Can't get vCard for {entity_jid}: {e}"
284 ).format(entity_jid=entity_jid, e=e))
285 else:
286 log.debug(_("VCard found"))
287 return await self.v_card_2_dict(client, vcard_elt, entity_jid)
288
289 async def get_avatar(
290 self,
291 client: SatXMPPEntity,
292 entity_jid: jid.JID
293 ) -> Optional[dict]:
294 """Get avatar data
295
296 @param entity: entity to get avatar from
297 @return: avatar metadata, or None if no avatar has been found
298 """
299 entity_jid = self._i.get_identity_jid(client, entity_jid)
300 hashes_cache = client._xep_0054_avatar_hashes
301 vcard = await self.get_card(client, entity_jid)
302 if vcard is None:
303 return None
304 try:
305 avatar_hash = hashes_cache[entity_jid.full()]
306 except KeyError:
307 if 'avatar' in vcard:
308 raise exceptions.InternalError(
309 "No avatar hash while avatar is found in vcard")
310 return None
311
312 if not avatar_hash:
313 return None
314
315 avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
316 return self._i.avatar_build_metadata(
317 avatar_cache['path'], avatar_cache['mime_type'], avatar_hash)
318
319 async def set_avatar(self, client, avatar_data, entity):
320 """Set avatar of the profile
321
322 @param avatar_data(dict): data of the image to use as avatar, as built by
323 IDENTITY plugin.
324 @param entity(jid.JID): entity whose avatar must be changed
325 """
326 vcard_elt = await self.update_vcard_elt(client, entity, ['PHOTO'])
327
328 iq_elt = client.IQ()
329 iq_elt.addChild(vcard_elt)
330 # metadata with encoded image are now filled at the right size/format
331 photo_elt = vcard_elt.addElement("PHOTO")
332 photo_elt.addElement("TYPE", content=avatar_data["media_type"])
333 photo_elt.addElement("BINVAL", content=avatar_data["base64"])
334
335 await iq_elt.send()
336
337 # FIXME: should send the current presence, not always "available" !
338 await client.presence.available()
339
340 async def get_nicknames(self, client, entity):
341 """get nick from cache, or check vCard
342
343 @param entity(jid.JID): entity to get nick from
344 @return(list[str]): nicknames found
345 """
346 vcard_data = await self.get_card(client, entity)
347 try:
348 return [vcard_data['nickname']]
349 except (KeyError, TypeError):
350 return []
351
352 async def set_nicknames(self, client, nicknames, entity):
353 """Update our vCard and set a nickname
354
355 @param nicknames(list[str]): new nicknames to use
356 only first item of this list will be used here
357 """
358 nick = nicknames[0].strip()
359
360 vcard_elt = await self.update_vcard_elt(client, entity, ['NICKNAME'])
361
362 if nick:
363 vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick)
364 iq_elt = client.IQ()
365 iq_elt.addChild(vcard_elt)
366 await iq_elt.send()
367
368
369 @implementer(iwokkel.IDisco)
370 class XEP_0054_handler(XMPPHandler):
371
372 def __init__(self, plugin_parent):
373 self.plugin_parent = plugin_parent
374 self.host = plugin_parent.host
375
376 def connectionInitialized(self):
377 self.xmlstream.addObserver(VCARD_UPDATE, self._update)
378
379 def getDiscoInfo(self, requestor, target, nodeIdentifier=""):
380 return [disco.DiscoFeature(NS_VCARD)]
381
382 def getDiscoItems(self, requestor, target, nodeIdentifier=""):
383 return []
384
385 async def update(self, presence):
386 """Called on <presence/> stanza with vcard data
387
388 Check for avatar information, and get VCard if needed
389 @param presence(domish.Element): <presence/> stanza
390 """
391 client = self.parent
392 entity_jid = self.plugin_parent._i.get_identity_jid(
393 client, jid.JID(presence["from"]))
394
395 try:
396 x_elt = next(presence.elements(NS_VCARD_UPDATE, "x"))
397 except StopIteration:
398 return
399
400 try:
401 photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo"))
402 except StopIteration:
403 return
404
405 given_hash = str(photo_elt).strip()
406 if given_hash == HASH_SHA1_EMPTY:
407 given_hash = ""
408
409 hashes_cache = client._xep_0054_avatar_hashes
410
411 old_hash = hashes_cache.get(entity_jid.full())
412
413 if old_hash == given_hash:
414 # no change, we can return…
415 if given_hash:
416 # …but we double check that avatar is in cache
417 avatar_cache = self.host.common_cache.get_metadata(given_hash)
418 if avatar_cache is None:
419 log.debug(
420 f"Avatar for [{entity_jid}] is known but not in cache, we get "
421 f"it"
422 )
423 # get_card will put the avatar in cache
424 await self.plugin_parent.get_card(client, entity_jid)
425 else:
426 log.debug(f"avatar for {entity_jid} is already in cache")
427 return
428
429 if given_hash is None:
430 # XXX: we use empty string to indicate that there is no avatar
431 given_hash = ""
432
433 await hashes_cache.aset(entity_jid.full(), given_hash)
434
435 if not given_hash:
436 await self.plugin_parent._i.update(
437 client, IMPORT_NAME, "avatar", None, entity_jid)
438 # the avatar has been removed, no need to go further
439 return
440
441 avatar_cache = self.host.common_cache.get_metadata(given_hash)
442 if avatar_cache is not None:
443 log.debug(
444 f"New avatar found for [{entity_jid}], it's already in cache, we use it"
445 )
446 await self.plugin_parent._i.update(
447 client,
448 IMPORT_NAME, "avatar",
449 {
450 'path': avatar_cache['path'],
451 'filename': avatar_cache['filename'],
452 'media_type': avatar_cache['mime_type'],
453 'cache_uid': given_hash,
454 },
455 entity_jid
456 )
457 else:
458 log.debug(
459 "New avatar found for [{entity_jid}], requesting vcard"
460 )
461 vcard = await self.plugin_parent.get_card(client, entity_jid)
462 if vcard is None:
463 log.warning(f"Unexpected empty vCard for {entity_jid}")
464 return
465 computed_hash = client._xep_0054_avatar_hashes[entity_jid.full()]
466 if computed_hash != given_hash:
467 log.warning(
468 "computed hash differs from given hash for {entity}:\n"
469 "computed: {computed}\ngiven: {given}".format(
470 entity=entity_jid, computed=computed_hash, given=given_hash
471 )
472 )
473
474 def _update(self, presence):
475 defer.ensureDeferred(self.update(presence))