Mercurial > libervia-backend
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0054.py Mon Apr 02 19:44:50 2018 +0200 @@ -0,0 +1,591 @@ +#!/usr/bin/env python2 +# -*- coding: utf-8 -*- + +# SAT plugin for managing xep-0054 +# Copyright (C) 2009-2018 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2014 Emmanuel Gil Peyrot (linkmauve@linkmauve.fr) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# 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 sat.core.i18n import _ +from sat.core.constants import Const as C +from sat.core.log import getLogger +log = getLogger(__name__) +from twisted.internet import threads, defer +from twisted.words.protocols.jabber import jid, error +from twisted.words.xish import domish +from twisted.python.failure import Failure + +from zope.interface import implements + +from wokkel import disco, iwokkel + +from base64 import b64decode, b64encode +from hashlib import sha1 +from sat.core import exceptions +from sat.memory import persistent +import mimetypes +try: + from PIL import Image +except: + raise exceptions.MissingModule(u"Missing module pillow, please download/install it from https://python-pillow.github.io") +from cStringIO import StringIO + +try: + from twisted.words.protocols.xmlstream import XMPPHandler +except ImportError: + from wokkel.subprotocols import XMPPHandler + +AVATAR_PATH = "avatars" +AVATAR_DIM = (64, 64) # FIXME: dim are not adapted to modern resolutions ! + +IQ_GET = '/iq[@type="get"]' +NS_VCARD = 'vcard-temp' +VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests + +PRESENCE = '/presence' +NS_VCARD_UPDATE = 'vcard-temp:x:update' +VCARD_UPDATE = PRESENCE + '/x[@xmlns="' + NS_VCARD_UPDATE + '"]' + +CACHED_DATA = {'avatar', 'nick'} +MAX_AGE = 60 * 60 * 24 * 365 + +PLUGIN_INFO = { + C.PI_NAME: "XEP 0054 Plugin", + C.PI_IMPORT_NAME: "XEP-0054", + C.PI_TYPE: "XEP", + C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ["XEP-0045"], + C.PI_MAIN: "XEP_0054", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""Implementation of vcard-temp""") +} + + +class XEP_0054(object): + #TODO: - check that nickname is ok + # - refactor the code/better use of Wokkel + # - get missing values + + def __init__(self, host): + log.info(_(u"Plugin XEP_0054 initialization")) + self.host = host + host.bridge.addMethod(u"avatarGet", u".plugin", in_sign=u'sbbs', out_sign=u's', method=self._getAvatar, async=True) + host.bridge.addMethod(u"avatarSet", u".plugin", in_sign=u'ss', out_sign=u'', method=self._setAvatar, async=True) + host.trigger.add(u"presence_available", self.presenceAvailableTrigger) + host.memory.setSignalOnUpdate(u"avatar") + host.memory.setSignalOnUpdate(u"nick") + + def getHandler(self, client): + return XEP_0054_handler(self) + + def isRoom(self, client, entity_jid): + """Tell if a jid is a MUC one + + @param entity_jid(jid.JID): full or bare jid of the entity check + @return (bool): True if the bare jid of the entity is a room jid + """ + try: + muc_plg = self.host.plugins['XEP-0045'] + except KeyError: + return False + + try: + muc_plg.checkRoomJoined(client, entity_jid.userhostJID()) + except exceptions.NotFound: + return False + else: + return True + + def getBareOrFull(self, client, jid_): + """use full jid if jid_ is an occupant of a room, bare jid else + + @param jid_(jid.JID): entity to test + @return (jid.JID): bare or full jid + """ + if jid_.resource: + if not self.isRoom(client, jid_): + return jid_.userhostJID() + return jid_ + + def presenceAvailableTrigger(self, presence_elt, client): + if client.jid.userhost() in client._cache_0054: + try: + avatar_hash = client._cache_0054[client.jid.userhost()]['avatar'] + except KeyError: + log.info(u"No avatar in cache for {}".format(client.jid.userhost())) + return True + x_elt = domish.Element((NS_VCARD_UPDATE, 'x')) + x_elt.addElement('photo', content=avatar_hash) + presence_elt.addChild(x_elt) + return True + + @defer.inlineCallbacks + def profileConnecting(self, client): + client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, client.profile) + yield client._cache_0054.load() + self._fillCachedValues(client.profile) + + def _fillCachedValues(self, profile): + #FIXME: this may need to be reworked + # the current naive approach keeps a map between all jids + # in persistent cache, then put avatar hashs in memory. + # Hashes should be shared between profiles (or not ? what + # if the avatar is different depending on who is requesting it + # this is not possible with vcard-tmp, but it is with XEP-0084). + # Loading avatar on demand per jid may be a option to investigate. + client = self.host.getClient(profile) + for jid_s, data in client._cache_0054.iteritems(): + jid_ = jid.JID(jid_s) + for name in CACHED_DATA: + try: + value = data[name] + if value is None: + log.error(u"{name} value for {jid_} is None, ignoring".format(name=name, jid_=jid_)) + continue + self.host.memory.updateEntityData(jid_, name, data[name], silent=True, profile_key=profile) + except KeyError: + pass + + def updateCache(self, client, jid_, name, value): + """update cache value + + save value in memory in case of change + @param jid_(jid.JID): jid of the owner of the vcard + @param name(str): name of the item which changed + @param value(unicode, None): new value of the item + None to delete + """ + jid_ = self.getBareOrFull(client, jid_) + jid_s = jid_.full() + + if value is None: + try: + self.host.memory.delEntityDatum(jid_, name, client.profile) + except (KeyError, exceptions.UnknownEntityError): + pass + if name in CACHED_DATA: + try: + del client._cache_0054[jid_s][name] + except KeyError: + pass + else: + client._cache_0054.force(jid_s) + else: + self.host.memory.updateEntityData(jid_, name, value, profile_key=client.profile) + if name in CACHED_DATA: + client._cache_0054.setdefault(jid_s, {})[name] = value + client._cache_0054.force(jid_s) + + def getCache(self, client, entity_jid, name): + """return cached value for jid + + @param entity_jid(jid.JID): target contact + @param name(unicode): name of the value ('nick' or 'avatar') + @return(unicode, None): wanted value or None""" + entity_jid = self.getBareOrFull(client, entity_jid) + try: + data = self.host.memory.getEntityData(entity_jid, [name], client.profile) + except exceptions.UnknownEntityError: + return None + return data.get(name) + + def savePhoto(self, client, photo_elt, entity_jid): + """Parse a <PHOTO> photo_elt and save the picture""" + # XXX: this method is launched in a separate thread + try: + mime_type = unicode(photo_elt.elements(NS_VCARD, 'TYPE').next()) + except StopIteration: + log.warning(u"no MIME type found, assuming image/png") + mime_type = u"image/png" + else: + if not mime_type: + log.warning(u"empty MIME type, assuming image/png") + mime_type = u"image/png" + elif mime_type not in ("image/gif", "image/jpeg", "image/png"): + if mime_type == "image/x-png": + # XXX: this old MIME type is still used by some clients + mime_type = "image/png" + else: + # TODO: handle other image formats (svg?) + log.warning(u"following avatar image format is not handled: {type} [{jid}]".format( + type=mime_type, jid=entity_jid.full())) + raise Failure(exceptions.DataError()) + + ext = mimetypes.guess_extension(mime_type, strict=False) + assert ext is not None + if ext == u'.jpe': + ext = u'.jpg' + log.debug(u'photo of type {type} with extension {ext} found [{jid}]'.format( + type=mime_type, ext=ext, jid=entity_jid.full())) + try: + buf = str(photo_elt.elements(NS_VCARD, 'BINVAL').next()) + except StopIteration: + log.warning(u"BINVAL element not found") + raise Failure(exceptions.NotFound()) + if not buf: + log.warning(u"empty avatar for {jid}".format(jid=entity_jid.full())) + raise Failure(exceptions.NotFound()) + log.debug(_(u'Decoding binary')) + decoded = b64decode(buf) + del buf + image_hash = sha1(decoded).hexdigest() + with client.cache.cacheData( + PLUGIN_INFO['import_name'], + image_hash, + mime_type, + # we keep in cache for 1 year + MAX_AGE + ) as f: + f.write(decoded) + return image_hash + + @defer.inlineCallbacks + def vCard2Dict(self, client, vcard, entity_jid): + """Convert a VCard to a dict, and save binaries""" + log.debug((u"parsing vcard")) + vcard_dict = {} + + for elem in vcard.elements(): + if elem.name == 'FN': + vcard_dict['fullname'] = unicode(elem) + elif elem.name == 'NICKNAME': + vcard_dict['nick'] = unicode(elem) + self.updateCache(client, entity_jid, 'nick', vcard_dict['nick']) + elif elem.name == 'URL': + vcard_dict['website'] = unicode(elem) + elif elem.name == 'EMAIL': + vcard_dict['email'] = unicode(elem) + elif elem.name == 'BDAY': + vcard_dict['birthday'] = unicode(elem) + elif elem.name == 'PHOTO': + # TODO: handle EXTVAL + try: + avatar_hash = yield threads.deferToThread( + self.savePhoto, client, elem, entity_jid) + except (exceptions.DataError, exceptions.NotFound) as e: + avatar_hash = '' + vcard_dict['avatar'] = avatar_hash + except Exception as e: + log.error(u"avatar saving error: {}".format(e)) + avatar_hash = None + else: + vcard_dict['avatar'] = avatar_hash + self.updateCache(client, entity_jid, 'avatar', avatar_hash) + else: + log.debug(u'FIXME: [{}] VCard tag is not managed yet'.format(elem.name)) + + # if a data in cache doesn't exist anymore, we need to delete it + # so we check CACHED_DATA no gotten (i.e. not in vcard_dict keys) + # and we reset them + for datum in CACHED_DATA.difference(vcard_dict.keys()): + log.debug(u"reseting vcard datum [{datum}] for {entity}".format(datum=datum, entity=entity_jid.full())) + self.updateCache(client, entity_jid, datum, None) + + defer.returnValue(vcard_dict) + + def _vCardCb(self, vcard_elt, to_jid, client): + """Called after the first get IQ""" + log.debug(_("VCard found")) + iq_elt = vcard_elt.parent + try: + from_jid = jid.JID(iq_elt["from"]) + except KeyError: + from_jid = client.jid.userhostJID() + d = self.vCard2Dict(client, vcard_elt, from_jid) + return d + + def _vCardEb(self, failure_, to_jid, client): + """Called when something is wrong with registration""" + log.warning(u"Can't get vCard for {jid}: {failure}".format(jid=to_jid.full, failure=failure_)) + self.updateCache(client, to_jid, "avatar", None) + + def _getVcardElt(self, iq_elt): + return iq_elt.elements(NS_VCARD, "vCard").next() + + def getCardRaw(self, client, entity_jid): + """get raw vCard XML + + params are as in [getCard] + """ + entity_jid = self.getBareOrFull(client, entity_jid) + log.debug(u"Asking for {}'s VCard".format(entity_jid.full())) + reg_request = client.IQ('get') + reg_request["from"] = client.jid.full() + reg_request["to"] = entity_jid.full() + reg_request.addElement('vCard', NS_VCARD) + d = reg_request.send(entity_jid.full()) + d.addCallback(self._getVcardElt) + return d + + def getCard(self, client, entity_jid): + """Ask server for VCard + + @param entity_jid(jid.JID): jid from which we want the VCard + @result: id to retrieve the profile + """ + d = self.getCardRaw(client, entity_jid) + d.addCallbacks(self._vCardCb, self._vCardEb, callbackArgs=[entity_jid, client], errbackArgs=[entity_jid, client]) + return d + + def _getCardCb(self, dummy, client, entity): + try: + return client._cache_0054[entity.full()]['avatar'] + except KeyError: + raise Failure(exceptions.NotFound()) + + def _getAvatar(self, entity, cache_only, hash_only, profile): + client = self.host.getClient(profile) + d = self.getAvatar(client, jid.JID(entity), cache_only, hash_only) + d.addErrback(lambda dummy: '') + + return d + + def getAvatar(self, client, entity, cache_only=True, hash_only=False): + """get avatar full path or hash + + if avatar is not in local cache, it will be requested to the server + @param entity(jid.JID): entity to get avatar from + @param cache_only(bool): if False, will request vCard if avatar is + not in cache + @param hash_only(bool): if True only return hash, not full path + @raise exceptions.NotFound: no avatar found + """ + if not entity.resource and self.isRoom(client, entity): + raise exceptions.NotFound + entity = self.getBareOrFull(client, entity) + full_path = None + + try: + # we first check if we have avatar in cache + avatar_hash = client._cache_0054[entity.full()]['avatar'] + if avatar_hash: + # avatar is known and exists + full_path = client.cache.getFilePath(avatar_hash) + if full_path is None: + # cache file is not available (probably expired) + raise KeyError + else: + # avatar has already been checked but it is not set + full_path = u'' + except KeyError: + # avatar is not in cache + if cache_only: + return defer.fail(Failure(exceptions.NotFound())) + # we request vCard to get avatar + d = self.getCard(client, entity) + d.addCallback(self._getCardCb, client, entity) + else: + # avatar is in cache, we can return hash + d = defer.succeed(avatar_hash) + + if not hash_only: + # full path is requested + if full_path is None: + d.addCallback(client.cache.getFilePath) + else: + d.addCallback(lambda dummy: full_path) + return d + + @defer.inlineCallbacks + def getNick(self, client, entity): + """get nick from cache, or check vCard + + @param entity(jid.JID): entity to get nick from + @return(unicode, None): nick or None if not found + """ + nick = self.getCache(client, entity, u'nick') + if nick is not None: + defer.returnValue(nick) + yield self.getCard(client, entity) + defer.returnValue(self.getCache(client, entity, u'nick')) + + @defer.inlineCallbacks + def setNick(self, client, nick): + """update our vCard and set a nickname + + @param nick(unicode): new nickname to use + """ + jid_ = client.jid.userhostJID() + try: + vcard_elt = yield self.getCardRaw(client, jid_) + except error.StanzaError as e: + if e.condition == 'item-not-found': + vcard_elt = domish.Element((NS_VCARD, 'vCard')) + else: + raise e + try: + nickname_elt = next(vcard_elt.elements(NS_VCARD, u'NICKNAME')) + except StopIteration: + pass + else: + vcard_elt.children.remove(nickname_elt) + + nickname_elt = vcard_elt.addElement((NS_VCARD, u'NICKNAME'), content=nick) + iq_elt = client.IQ() + vcard_elt = iq_elt.addChild(vcard_elt) + yield iq_elt.send() + self.updateCache(client, jid_, u'nick', unicode(nick)) + + def _buildSetAvatar(self, client, vcard_elt, file_path): + # XXX: this method is executed in a separate thread + try: + img = Image.open(file_path) + except IOError: + return Failure(exceptions.DataError(u"Can't open image")) + + if img.size != AVATAR_DIM: + img.thumbnail(AVATAR_DIM) + if img.size[0] != img.size[1]: # we need to crop first + left, upper = (0, 0) + right, lower = img.size + offset = abs(right - lower) / 2 + if right == min(img.size): + upper += offset + lower -= offset + else: + left += offset + right -= offset + img = img.crop((left, upper, right, lower)) + img_buf = StringIO() + img.save(img_buf, 'PNG') + + photo_elt = vcard_elt.addElement('PHOTO') + photo_elt.addElement('TYPE', content='image/png') + photo_elt.addElement('BINVAL', content=b64encode(img_buf.getvalue())) + image_hash = sha1(img_buf.getvalue()).hexdigest() + with client.cache.cacheData( + PLUGIN_INFO['import_name'], + image_hash, + "image/png", + MAX_AGE + ) as f: + f.write(img_buf.getvalue()) + return image_hash + + def _setAvatar(self, file_path, profile_key=C.PROF_KEY_NONE): + client = self.host.getClient(profile_key) + return self.setAvatar(client, file_path) + + @defer.inlineCallbacks + def setAvatar(self, client, file_path): + """Set avatar of the profile + + @param file_path: path of the image of the avatar + """ + try: + # we first check if a vcard already exists, to keep data + vcard_elt = yield self.getCardRaw(client, client.jid.userhostJID()) + except error.StanzaError as e: + if e.condition == 'item-not-found': + vcard_elt = domish.Element((NS_VCARD, 'vCard')) + else: + raise e + else: + # the vcard exists, we need to remove PHOTO element as we'll make a new one + try: + photo_elt = next(vcard_elt.elements(NS_VCARD, u'PHOTO')) + except StopIteration: + pass + else: + vcard_elt.children.remove(photo_elt) + + iq_elt = client.IQ() + iq_elt.addChild(vcard_elt) + image_hash = yield threads.deferToThread(self._buildSetAvatar, client, vcard_elt, file_path) + # image is now at the right size/format + + self.updateCache(client, client.jid.userhostJID(), 'avatar', image_hash) + yield iq_elt.send() + client.presence.available() # FIXME: should send the current presence, not always "available" ! + + +class XEP_0054_handler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, plugin_parent): + self.plugin_parent = plugin_parent + self.host = plugin_parent.host + + def connectionInitialized(self): + self.xmlstream.addObserver(VCARD_UPDATE, self.update) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + return [disco.DiscoFeature(NS_VCARD)] + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] + + def _checkAvatarHash(self, dummy, client, entity, given_hash): + """check that hash in cash (i.e. computed hash) is the same as given one""" + # XXX: if they differ, the avater will be requested on each connection + # TODO: try to avoid re-requesting avatar in this case + computed_hash = self.plugin_parent.getCache(client, entity, 'avatar') + if computed_hash != given_hash: + log.warning(u"computed hash differs from given hash for {entity}:\n" + "computed: {computed}\ngiven: {given}".format( + entity=entity, computed=computed_hash, given=given_hash)) + + def update(self, presence): + """Called on <presence/> stanza with vcard data + + Check for avatar information, and get VCard if needed + @param presend(domish.Element): <presence/> stanza + """ + client = self.parent + entity_jid = self.plugin_parent.getBareOrFull(client, jid.JID(presence['from'])) + #FIXME: wokkel's data_form should be used here + try: + x_elt = presence.elements(NS_VCARD_UPDATE, 'x').next() + except StopIteration: + return + + try: + photo_elt = x_elt.elements(NS_VCARD_UPDATE, 'photo').next() + except StopIteration: + return + + hash_ = unicode(photo_elt).strip() + if hash_ == C.HASH_SHA1_EMPTY: + hash_ = u'' + old_avatar = self.plugin_parent.getCache(client, entity_jid, 'avatar') + + if old_avatar == hash_: + # no change, we can return... + if hash_: + # ...but we double check that avatar is in cache + file_path = client.cache.getFilePath(hash_) + if file_path is None: + log.error(u"Avatar for [{}] should be in cache but it is not! We get it".format(entity_jid.full())) + self.plugin_parent.getCard(client, entity_jid) + else: + log.debug(u"avatar for {} already in cache".format(entity_jid.full())) + return + + if not hash_: + # the avatar has been removed + # XXX: we use empty string instead of None to indicate that we took avatar + # but it is empty on purpose + self.plugin_parent.updateCache(client, entity_jid, 'avatar', '') + return + + file_path = client.cache.getFilePath(hash_) + if file_path is not None: + log.debug(u"New avatar found for [{}], it's already in cache, we use it".format(entity_jid.full())) + self.plugin_parent.updateCache(client, entity_jid, 'avatar', hash_) + else: + log.debug(u'New avatar found for [{}], requesting vcard'.format(entity_jid.full())) + d = self.plugin_parent.getCard(client, entity_jid) + d.addCallback(self._checkAvatarHash, client, entity_jid, hash_)