view src/plugins/plugin_xep_0054.py @ 2123:c42aab22c2c0

plugin XEP-0054, quick frontend(app): various improvments: - memory.cache is now used - getAvatar and setAvatar has been renamed to avatarGet and avatarSet to follow new convention - getAvatar now return (optionally) full path, and may request vCard or only return avatar in cache - getAvatarFile has been removed as it is not needed anymore (avatarGet can return full path) - getCard has been removed from bridge as it was only used to request avatar - new internal method getBareOfFull return jid to use depending of the jid being part of a MUC or not - cache is now set per profile in client, instead of a general one for all profiles - thanks to the use of memory.cache, correct extension is now used in saved file, according to MIME type - fixed and better cache handling - a warning message is shown if given avatar hash differs from computed one - empty hash value is now in a constant, and ignored if received - QuickApp has been updated to follow new behaviour - Primitivus has been fixed (it was not declaring not using avatars correclty) - jp has been updated to follow new methods name
author Goffi <goffi@goffi.org>
date Sun, 15 Jan 2017 17:51:37 +0100
parents 11fb5f5e2f89
children 1d3f73e065e1
line wrap: on
line source

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

# SAT plugin for managing xep-0054
# Copyright (C) 2009-2016 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
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 = {
    "name": "XEP 0054 Plugin",
    "import_name": "XEP-0054",
    "type": "XEP",
    "protocols": ["XEP-0054", "XEP-0153"],
    "dependencies": [],
    "recommendations": ["XEP-0045"],
    "main": "XEP_0054",
    "handler": "yes",
    "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, profile):
        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:
            self.host.plugins['XEP-0045'].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, profile):
        client = self.host.getClient(profile)
        client._cache_0054 = persistent.PersistentBinaryDict(NS_VCARD, profile)
        yield client._cache_0054.load()
        self._fillCachedValues(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: target contact
        @param name: name of the value ('nick' or 'avatar')
        @return: 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
            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, iq_elt, to_jid, client):
        """Called after the first get IQ"""
        log.debug(_("VCard found"))

        try:
            vcard_elt = iq_elt.elements(NS_VCARD, "vCard").next()
        except StopIteration:
            log.warning(u"Can't find vCard element in answer for jid {jid}", jid=to_jid.full())
            return
        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 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
        """
        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()).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))
        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
        """
        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

    def _buildSetAvatar(self, client, vcard_set, 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, Image.ANTIALIAS)
            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')

        vcard_elt = vcard_set.addElement('vCard', NS_VCARD)
        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 vcard_set, 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)

    def setAvatar(self, client, file_path):
        """Set avatar of the profile

        @param file_path: path of the image of the avatar
        """
        #TODO: This is a temporary way of setting the avatar, as other VCard information is not managed.
        #      A proper full VCard management should be done (and more generaly a public/private profile)
        vcard_set = client.IQ()
        d = threads.deferToThread(self._buildSetAvatar, client, vcard_set, file_path)

        def elementBuilt(result):
            """Called once the image is at the right size/format, and the vcard set element is build"""
            set_avatar_elt, image_hash = result
            self.updateCache(client, client.jid.userhostJID(), 'avatar', image_hash)
            return set_avatar_elt.send().addCallback(lambda ignore: client.presence.available()) # FIXME: should send the current presence, not always "available" !

        d.addCallback(elementBuilt)

        return d


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_)