comparison sat/plugins/plugin_xep_0054.py @ 2562:26edcf3a30eb

core, setup: huge cleaning: - moved directories from src and frontends/src to sat and sat_frontends, which is the recommanded naming convention - move twisted directory to root - removed all hacks from setup.py, and added missing dependencies, it is now clean - use https URL for website in setup.py - removed "Environment :: X11 Applications :: GTK", as wix is deprecated and removed - renamed sat.sh to sat and fixed its installation - added python_requires to specify Python version needed - replaced glib2reactor which use deprecated code by gtk3reactor sat can now be installed directly from virtualenv without using --system-site-packages anymore \o/
author Goffi <goffi@goffi.org>
date Mon, 02 Apr 2018 19:44:50 +0200
parents src/plugins/plugin_xep_0054.py@4001aa395a04
children 395a3d1c2888
comparison
equal deleted inserted replaced
2561:bd30dc3ffe5a 2562:26edcf3a30eb
1 #!/usr/bin/env python2
2 # -*- coding: utf-8 -*-
3
4 # SAT plugin for managing xep-0054
5 # Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org)
6 # Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr)
7
8 # This program is free software: you can redistribute it and/or modify
9 # it under the terms of the GNU Affero General Public License as published by
10 # the Free Software Foundation, either version 3 of the License, or
11 # (at your option) any later version.
12
13 # This program is distributed in the hope that it will be useful,
14 # but WITHOUT ANY WARRANTY; without even the implied warranty of
15 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16 # GNU Affero General Public License for more details.
17
18 # You should have received a copy of the GNU Affero General Public License
19 # along with this program. If not, see <http://www.gnu.org/licenses/>.
20
21 from sat.core.i18n import _
22 from sat.core.constants import Const as C
23 from sat.core.log import getLogger
24 log = getLogger(__name__)
25 from twisted.internet import threads, defer
26 from twisted.words.protocols.jabber import jid, error
27 from twisted.words.xish import domish
28 from twisted.python.failure import Failure
29
30 from zope.interface import implements
31
32 from wokkel import disco, iwokkel
33
34 from base64 import b64decode, b64encode
35 from hashlib import sha1
36 from sat.core import exceptions
37 from sat.memory import persistent
38 import mimetypes
39 try:
40 from PIL import Image
41 except:
42 raise exceptions.MissingModule(u"Missing module pillow, please download/install it from https://python-pillow.github.io")
43 from cStringIO import StringIO
44
45 try:
46 from twisted.words.protocols.xmlstream import XMPPHandler
47 except ImportError:
48 from wokkel.subprotocols import XMPPHandler
49
50 AVATAR_PATH = "avatars"
51 AVATAR_DIM = (64, 64) # FIXME: dim are not adapted to modern resolutions !
52
53 IQ_GET = '/iq[@type="get"]'
54 NS_VCARD = 'vcard-temp'
55 VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests
56
57 PRESENCE = '/presence'
58 NS_VCARD_UPDATE = 'vcard-temp:x:update'
59 VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]'
60
61 CACHED_DATA = {'avatar', 'nick'}
62 MAX_AGE = 60 * 60 * 24 * 365
63
64 PLUGIN_INFO = {
65 C.PI_NAME: "XEP 0054 Plugin",
66 C.PI_IMPORT_NAME: "XEP-0054",
67 C.PI_TYPE: "XEP",
68 C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"],
69 C.PI_DEPENDENCIES: [],
70 C.PI_RECOMMENDATIONS: ["XEP-0045"],
71 C.PI_MAIN: "XEP_0054",
72 C.PI_HANDLER: "yes",
73 C.PI_DESCRIPTION: _("""Implementation of vcard-temp""")
74 }
75
76
77 class XEP_0054(object):
78 #TODO: - check that nickname is ok
79 # - refactor the code/better use of Wokkel
80 # - get missing values
81
82 def __init__(self, host):
83 log.info(_(u"Plugin XEP_0054 initialization"))
84 self.host = host
85 host.bridge.addMethod(u"avatarGet", u".plugin", in_sign=u'sbbs', out_sign=u's', method=self._getAvatar, async=True)
86 host.bridge.addMethod(u"avatarSet", u".plugin", in_sign=u'ss', out_sign=u'', method=self._setAvatar, async=True)
87 host.trigger.add(u"presence_available", self.presenceAvailableTrigger)
88 host.memory.setSignalOnUpdate(u"avatar")
89 host.memory.setSignalOnUpdate(u"nick")
90
91 def getHandler(self, client):
92 return XEP_0054_handler(self)
93
94 def isRoom(self, client, entity_jid):
95 """Tell if a jid is a MUC one
96
97 @param entity_jid(jid.JID): full or bare jid of the entity check
98 @return (bool): True if the bare jid of the entity is a room jid
99 """
100 try:
101 muc_plg = self.host.plugins['XEP-0045']
102 except KeyError:
103 return False
104
105 try:
106 muc_plg.checkRoomJoined(client, entity_jid.userhostJID())
107 except exceptions.NotFound:
108 return False
109 else:
110 return True
111
112 def getBareOrFull(self, client, jid_):
113 """use full jid if jid_ is an occupant of a room, bare jid else
114
115 @param jid_(jid.JID): entity to test
116 @return (jid.JID): bare or full jid
117 """
118 if jid_.resource:
119 if not self.isRoom(client, jid_):
120 return jid_.userhostJID()
121 return jid_
122
123 def presenceAvailableTrigger(self, presence_elt, client):
124 if client.jid.userhost() in client._cache_0054:
125 try:
126 avatar_hash = client._cache_0054[client.jid.userhost()]['avatar']
127 except KeyError:
128 log.info(u"No avatar in cache for {}".format(client.jid.userhost()))
129 return True
130 x_elt = domish.Element((NS_VCARD_UPDATE, 'x'))
131 x_elt.addElement('photo', content=avatar_hash)
132 presence_elt.addChild(x_elt)
133 return True
134
135 @defer.inlineCallbacks
136 def profileConnecting(self, client):
137 client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, client.profile)
138 yield client._cache_0054.load()
139 self._fillCachedValues(client.profile)
140
141 def _fillCachedValues(self, profile):
142 #FIXME: this may need to be reworked
143 # the current naive approach keeps a map between all jids
144 # in persistent cache, then put avatar hashs in memory.
145 # Hashes should be shared between profiles (or not ? what
146 # if the avatar is different depending on who is requesting it
147 # this is not possible with vcard-tmp, but it is with XEP-0084).
148 # Loading avatar on demand per jid may be a option to investigate.
149 client = self.host.getClient(profile)
150 for jid_s, data in client._cache_0054.iteritems():
151 jid_ = jid.JID(jid_s)
152 for name in CACHED_DATA:
153 try:
154 value = data[name]
155 if value is None:
156 log.error(u"{name} value for {jid_} is None, ignoring".format(name=name, jid_=jid_))
157 continue
158 self.host.memory.updateEntityData(jid_, name, data[name], silent=True, profile_key=profile)
159 except KeyError:
160 pass
161
162 def updateCache(self, client, jid_, name, value):
163 """update cache value
164
165 save value in memory in case of change
166 @param jid_(jid.JID): jid of the owner of the vcard
167 @param name(str): name of the item which changed
168 @param value(unicode, None): new value of the item
169 None to delete
170 """
171 jid_ = self.getBareOrFull(client, jid_)
172 jid_s = jid_.full()
173
174 if value is None:
175 try:
176 self.host.memory.delEntityDatum(jid_, name, client.profile)
177 except (KeyError, exceptions.UnknownEntityError):
178 pass
179 if name in CACHED_DATA:
180 try:
181 del client._cache_0054[jid_s][name]
182 except KeyError:
183 pass
184 else:
185 client._cache_0054.force(jid_s)
186 else:
187 self.host.memory.updateEntityData(jid_, name, value, profile_key=client.profile)
188 if name in CACHED_DATA:
189 client._cache_0054.setdefault(jid_s, {})[name] = value
190 client._cache_0054.force(jid_s)
191
192 def getCache(self, client, entity_jid, name):
193 """return cached value for jid
194
195 @param entity_jid(jid.JID): target contact
196 @param name(unicode): name of the value ('nick' or 'avatar')
197 @return(unicode, None): wanted value or None"""
198 entity_jid = self.getBareOrFull(client, entity_jid)
199 try:
200 data = self.host.memory.getEntityData(entity_jid, [name], client.profile)
201 except exceptions.UnknownEntityError:
202 return None
203 return data.get(name)
204
205 def savePhoto(self, client, photo_elt, entity_jid):
206 """Parse a <PHOTO> photo_elt and save the picture"""
207 # XXX: this method is launched in a separate thread
208 try:
209 mime_type = unicode(photo_elt.elements(NS_VCARD, 'TYPE').next())
210 except StopIteration:
211 log.warning(u"no MIME type found, assuming image/png")
212 mime_type = u"image/png"
213 else:
214 if not mime_type:
215 log.warning(u"empty MIME type, assuming image/png")
216 mime_type = u"image/png"
217 elif mime_type not in ("image/gif", "image/jpeg", "image/png"):
218 if mime_type == "image/x-png":
219 # XXX: this old MIME type is still used by some clients
220 mime_type = "image/png"
221 else:
222 # TODO: handle other image formats (svg?)
223 log.warning(u"following avatar image format is not handled: {type} [{jid}]".format(
224 type=mime_type, jid=entity_jid.full()))
225 raise Failure(exceptions.DataError())
226
227 ext = mimetypes.guess_extension(mime_type, strict=False)
228 assert ext is not None
229 if ext == u'.jpe':
230 ext = u'.jpg'
231 log.debug(u'photo of type {type} with extension {ext} found [{jid}]'.format(
232 type=mime_type, ext=ext, jid=entity_jid.full()))
233 try:
234 buf = str(photo_elt.elements(NS_VCARD, 'BINVAL').next())
235 except StopIteration:
236 log.warning(u"BINVAL element not found")
237 raise Failure(exceptions.NotFound())
238 if not buf:
239 log.warning(u"empty avatar for {jid}".format(jid=entity_jid.full()))
240 raise Failure(exceptions.NotFound())
241 log.debug(_(u'Decoding binary'))
242 decoded = b64decode(buf)
243 del buf
244 image_hash = sha1(decoded).hexdigest()
245 with client.cache.cacheData(
246 PLUGIN_INFO['import_name'],
247 image_hash,
248 mime_type,
249 # we keep in cache for 1 year
250 MAX_AGE
251 ) as f:
252 f.write(decoded)
253 return image_hash
254
255 @defer.inlineCallbacks
256 def vCard2Dict(self, client, vcard, entity_jid):
257 """Convert a VCard to a dict, and save binaries"""
258 log.debug((u"parsing vcard"))
259 vcard_dict = {}
260
261 for elem in vcard.elements():
262 if elem.name == 'FN':
263 vcard_dict['fullname'] = unicode(elem)
264 elif elem.name == 'NICKNAME':
265 vcard_dict['nick'] = unicode(elem)
266 self.updateCache(client, entity_jid, 'nick', vcard_dict['nick'])
267 elif elem.name == 'URL':
268 vcard_dict['website'] = unicode(elem)
269 elif elem.name == 'EMAIL':
270 vcard_dict['email'] = unicode(elem)
271 elif elem.name == 'BDAY':
272 vcard_dict['birthday'] = unicode(elem)
273 elif elem.name == 'PHOTO':
274 # TODO: handle EXTVAL
275 try:
276 avatar_hash = yield threads.deferToThread(
277 self.savePhoto, client, elem, entity_jid)
278 except (exceptions.DataError, exceptions.NotFound) as e:
279 avatar_hash = ''
280 vcard_dict['avatar'] = avatar_hash
281 except Exception as e:
282 log.error(u"avatar saving error: {}".format(e))
283 avatar_hash = None
284 else:
285 vcard_dict['avatar'] = avatar_hash
286 self.updateCache(client, entity_jid, 'avatar', avatar_hash)
287 else:
288 log.debug(u'FIXME: [{}] VCard tag is not managed yet'.format(elem.name))
289
290 # if a data in cache doesn't exist anymore, we need to delete it
291 # so we check CACHED_DATA no gotten (i.e. not in vcard_dict keys)
292 # and we reset them
293 for datum in CACHED_DATA.difference(vcard_dict.keys()):
294 log.debug(u"reseting vcard datum [{datum}] for {entity}".format(datum=datum, entity=entity_jid.full()))
295 self.updateCache(client, entity_jid, datum, None)
296
297 defer.returnValue(vcard_dict)
298
299 def _vCardCb(self, vcard_elt, to_jid, client):
300 """Called after the first get IQ"""
301 log.debug(_("VCard found"))
302 iq_elt = vcard_elt.parent
303 try:
304 from_jid = jid.JID(iq_elt["from"])
305 except KeyError:
306 from_jid = client.jid.userhostJID()
307 d = self.vCard2Dict(client, vcard_elt, from_jid)
308 return d
309
310 def _vCardEb(self, failure_, to_jid, client):
311 """Called when something is wrong with registration"""
312 log.warning(u"Can't get vCard for {jid}: {failure}".format(jid=to_jid.full, failure=failure_))
313 self.updateCache(client, to_jid, "avatar", None)
314
315 def _getVcardElt(self, iq_elt):
316 return iq_elt.elements(NS_VCARD, "vCard").next()
317
318 def getCardRaw(self, client, entity_jid):
319 """get raw vCard XML
320
321 params are as in [getCard]
322 """
323 entity_jid = self.getBareOrFull(client, entity_jid)
324 log.debug(u"Asking for {}'s VCard".format(entity_jid.full()))
325 reg_request = client.IQ('get')
326 reg_request["from"] = client.jid.full()
327 reg_request["to"] = entity_jid.full()
328 reg_request.addElement('vCard', NS_VCARD)
329 d = reg_request.send(entity_jid.full())
330 d.addCallback(self._getVcardElt)
331 return d
332
333 def getCard(self, client, entity_jid):
334 """Ask server for VCard
335
336 @param entity_jid(jid.JID): jid from which we want the VCard
337 @result: id to retrieve the profile
338 """
339 d = self.getCardRaw(client, entity_jid)
340 d.addCallbacks(self._vCardCb, self._vCardEb, callbackArgs=[entity_jid, client], errbackArgs=[entity_jid, client])
341 return d
342
343 def _getCardCb(self, dummy, client, entity):
344 try:
345 return client._cache_0054[entity.full()]['avatar']
346 except KeyError:
347 raise Failure(exceptions.NotFound())
348
349 def _getAvatar(self, entity, cache_only, hash_only, profile):
350 client = self.host.getClient(profile)
351 d = self.getAvatar(client, jid.JID(entity), cache_only, hash_only)
352 d.addErrback(lambda dummy: '')
353
354 return d
355
356 def getAvatar(self, client, entity, cache_only=True, hash_only=False):
357 """get avatar full path or hash
358
359 if avatar is not in local cache, it will be requested to the server
360 @param entity(jid.JID): entity to get avatar from
361 @param cache_only(bool): if False, will request vCard if avatar is
362 not in cache
363 @param hash_only(bool): if True only return hash, not full path
364 @raise exceptions.NotFound: no avatar found
365 """
366 if not entity.resource and self.isRoom(client, entity):
367 raise exceptions.NotFound
368 entity = self.getBareOrFull(client, entity)
369 full_path = None
370
371 try:
372 # we first check if we have avatar in cache
373 avatar_hash = client._cache_0054[entity.full()]['avatar']
374 if avatar_hash:
375 # avatar is known and exists
376 full_path = client.cache.getFilePath(avatar_hash)
377 if full_path is None:
378 # cache file is not available (probably expired)
379 raise KeyError
380 else:
381 # avatar has already been checked but it is not set
382 full_path = u''
383 except KeyError:
384 # avatar is not in cache
385 if cache_only:
386 return defer.fail(Failure(exceptions.NotFound()))
387 # we request vCard to get avatar
388 d = self.getCard(client, entity)
389 d.addCallback(self._getCardCb, client, entity)
390 else:
391 # avatar is in cache, we can return hash
392 d = defer.succeed(avatar_hash)
393
394 if not hash_only:
395 # full path is requested
396 if full_path is None:
397 d.addCallback(client.cache.getFilePath)
398 else:
399 d.addCallback(lambda dummy: full_path)
400 return d
401
402 @defer.inlineCallbacks
403 def getNick(self, client, entity):
404 """get nick from cache, or check vCard
405
406 @param entity(jid.JID): entity to get nick from
407 @return(unicode, None): nick or None if not found
408 """
409 nick = self.getCache(client, entity, u'nick')
410 if nick is not None:
411 defer.returnValue(nick)
412 yield self.getCard(client, entity)
413 defer.returnValue(self.getCache(client, entity, u'nick'))
414
415 @defer.inlineCallbacks
416 def setNick(self, client, nick):
417 """update our vCard and set a nickname
418
419 @param nick(unicode): new nickname to use
420 """
421 jid_ = client.jid.userhostJID()
422 try:
423 vcard_elt = yield self.getCardRaw(client, jid_)
424 except error.StanzaError as e:
425 if e.condition == 'item-not-found':
426 vcard_elt = domish.Element((NS_VCARD, 'vCard'))
427 else:
428 raise e
429 try:
430 nickname_elt = next(vcard_elt.elements(NS_VCARD, u'NICKNAME'))
431 except StopIteration:
432 pass
433 else:
434 vcard_elt.children.remove(nickname_elt)
435
436 nickname_elt = vcard_elt.addElement((NS_VCARD, u'NICKNAME'), content=nick)
437 iq_elt = client.IQ()
438 vcard_elt = iq_elt.addChild(vcard_elt)
439 yield iq_elt.send()
440 self.updateCache(client, jid_, u'nick', unicode(nick))
441
442 def _buildSetAvatar(self, client, vcard_elt, file_path):
443 # XXX: this method is executed in a separate thread
444 try:
445 img = Image.open(file_path)
446 except IOError:
447 return Failure(exceptions.DataError(u"Can't open image"))
448
449 if img.size != AVATAR_DIM:
450 img.thumbnail(AVATAR_DIM)
451 if img.size[0] != img.size[1]: # we need to crop first
452 left, upper = (0, 0)
453 right, lower = img.size
454 offset = abs(right - lower) / 2
455 if right == min(img.size):
456 upper += offset
457 lower -= offset
458 else:
459 left += offset
460 right -= offset
461 img = img.crop((left, upper, right, lower))
462 img_buf = StringIO()
463 img.save(img_buf, 'PNG')
464
465 photo_elt = vcard_elt.addElement('PHOTO')
466 photo_elt.addElement('TYPE', content='image/png')
467 photo_elt.addElement('BINVAL', content=b64encode(img_buf.getvalue()))
468 image_hash = sha1(img_buf.getvalue()).hexdigest()
469 with client.cache.cacheData(
470 PLUGIN_INFO['import_name'],
471 image_hash,
472 "image/png",
473 MAX_AGE
474 ) as f:
475 f.write(img_buf.getvalue())
476 return image_hash
477
478 def _setAvatar(self, file_path, profile_key=C.PROF_KEY_NONE):
479 client = self.host.getClient(profile_key)
480 return self.setAvatar(client, file_path)
481
482 @defer.inlineCallbacks
483 def setAvatar(self, client, file_path):
484 """Set avatar of the profile
485
486 @param file_path: path of the image of the avatar
487 """
488 try:
489 # we first check if a vcard already exists, to keep data
490 vcard_elt = yield self.getCardRaw(client, client.jid.userhostJID())
491 except error.StanzaError as e:
492 if e.condition == 'item-not-found':
493 vcard_elt = domish.Element((NS_VCARD, 'vCard'))
494 else:
495 raise e
496 else:
497 # the vcard exists, we need to remove PHOTO element as we'll make a new one
498 try:
499 photo_elt = next(vcard_elt.elements(NS_VCARD, u'PHOTO'))
500 except StopIteration:
501 pass
502 else:
503 vcard_elt.children.remove(photo_elt)
504
505 iq_elt = client.IQ()
506 iq_elt.addChild(vcard_elt)
507 image_hash = yield threads.deferToThread(self._buildSetAvatar, client, vcard_elt, file_path)
508 # image is now at the right size/format
509
510 self.updateCache(client, client.jid.userhostJID(), 'avatar', image_hash)
511 yield iq_elt.send()
512 client.presence.available() # FIXME: should send the current presence, not always "available" !
513
514
515 class XEP_0054_handler(XMPPHandler):
516 implements(iwokkel.IDisco)
517
518 def __init__(self, plugin_parent):
519 self.plugin_parent = plugin_parent
520 self.host = plugin_parent.host
521
522 def connectionInitialized(self):
523 self.xmlstream.addObserver(VCARD_UPDATE, self.update)
524
525 def getDiscoInfo(self, requestor, target, nodeIdentifier=''):
526 return [disco.DiscoFeature(NS_VCARD)]
527
528 def getDiscoItems(self, requestor, target, nodeIdentifier=''):
529 return []
530
531 def _checkAvatarHash(self, dummy, client, entity, given_hash):
532 """check that hash in cash (i.e. computed hash) is the same as given one"""
533 # XXX: if they differ, the avater will be requested on each connection
534 # TODO: try to avoid re-requesting avatar in this case
535 computed_hash = self.plugin_parent.getCache(client, entity, 'avatar')
536 if computed_hash != given_hash:
537 log.warning(u"computed hash differs from given hash for {entity}:\n"
538 "computed: {computed}\ngiven: {given}".format(
539 entity=entity, computed=computed_hash, given=given_hash))
540
541 def update(self, presence):
542 """Called on <presence/> stanza with vcard data
543
544 Check for avatar information, and get VCard if needed
545 @param presend(domish.Element): <presence/> stanza
546 """
547 client = self.parent
548 entity_jid = self.plugin_parent.getBareOrFull(client, jid.JID(presence['from']))
549 #FIXME: wokkel's data_form should be used here
550 try:
551 x_elt = presence.elements(NS_VCARD_UPDATE, 'x').next()
552 except StopIteration:
553 return
554
555 try:
556 photo_elt = x_elt.elements(NS_VCARD_UPDATE, 'photo').next()
557 except StopIteration:
558 return
559
560 hash_ = unicode(photo_elt).strip()
561 if hash_ == C.HASH_SHA1_EMPTY:
562 hash_ = u''
563 old_avatar = self.plugin_parent.getCache(client, entity_jid, 'avatar')
564
565 if old_avatar == hash_:
566 # no change, we can return...
567 if hash_:
568 # ...but we double check that avatar is in cache
569 file_path = client.cache.getFilePath(hash_)
570 if file_path is None:
571 log.error(u"Avatar for [{}] should be in cache but it is not! We get it".format(entity_jid.full()))
572 self.plugin_parent.getCard(client, entity_jid)
573 else:
574 log.debug(u"avatar for {} already in cache".format(entity_jid.full()))
575 return
576
577 if not hash_:
578 # the avatar has been removed
579 # XXX: we use empty string instead of None to indicate that we took avatar
580 # but it is empty on purpose
581 self.plugin_parent.updateCache(client, entity_jid, 'avatar', '')
582 return
583
584 file_path = client.cache.getFilePath(hash_)
585 if file_path is not None:
586 log.debug(u"New avatar found for [{}], it's already in cache, we use it".format(entity_jid.full()))
587 self.plugin_parent.updateCache(client, entity_jid, 'avatar', hash_)
588 else:
589 log.debug(u'New avatar found for [{}], requesting vcard'.format(entity_jid.full()))
590 d = self.plugin_parent.getCard(client, entity_jid)
591 d.addCallback(self._checkAvatarHash, client, entity_jid, hash_)