Mercurial > libervia-backend
view sat/plugins/plugin_xep_0054.py @ 3187:d92a144f3589
plugin download: use cache if dest_path is empty:
if dest_path is not set, the file will be downloaded in cache. A new name will be
generated using a hash of the uri. If the file is already downloded in cache, it won't be
downloaded again (in this case returned progress_id is an empty string).
author | Goffi <goffi@goffi.org> |
---|---|
date | Wed, 26 Feb 2020 22:03:11 +0100 |
parents | 554b3b632378 |
children | 5afd7416ca2d |
line wrap: on
line source
#!/usr/bin/env python3 # SAT plugin for managing xep-0054 # Copyright (C) 2009-2020 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/>. import mimetypes from base64 import b64decode, b64encode from hashlib import sha1 from pathlib import Path from zope.interface import implementer 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 wokkel import disco, iwokkel from sat.core import exceptions from sat.core.i18n import _ from sat.core.constants import Const as C from sat.core.log import getLogger from sat.memory import persistent log = getLogger(__name__) try: from PIL import Image except: raise exceptions.MissingModule( "Missing module pillow, please download/install it from https://python-pillow.github.io" ) import io 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 ! AVATAR_DIM = (128, 128) 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(_("Plugin XEP_0054 initialization")) self.host = host host.bridge.addMethod( "avatarGet", ".plugin", in_sign="sbbs", out_sign="s", method=self._getAvatar, async_=True, ) host.bridge.addMethod( "avatarSet", ".plugin", in_sign="ss", out_sign="", method=self._setAvatar, async_=True, ) host.trigger.add("presence_available", self.presenceAvailableTrigger) host.memory.setSignalOnUpdate("avatar") host.memory.setSignalOnUpdate("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("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 an option to investigate. client = self.host.getClient(profile) for jid_s, data in client._cache_0054.items(): jid_ = jid.JID(jid_s) for name in CACHED_DATA: try: value = data[name] if value is None: log.error( "{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 = str(next(photo_elt.elements(NS_VCARD, "TYPE"))) except StopIteration: mime_type = None else: if not mime_type: # MIME type not know, we'll only support PNG files # TODO: autodetection using e.g. "magic" module # (https://pypi.org/project/python-magic/) mime_type = None 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( "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 == ".jpe": ext = ".jpg" log.debug( "photo of type {type} with extension {ext} found [{jid}]".format( type=mime_type, ext=ext, jid=entity_jid.full() ) ) try: buf = str(next(photo_elt.elements(NS_VCARD, "BINVAL"))) except StopIteration: log.warning("BINVAL element not found") raise Failure(exceptions.NotFound()) if not buf: log.warning("empty avatar for {jid}".format(jid=entity_jid.full())) raise Failure(exceptions.NotFound()) if mime_type is None: log.warning(_("no MIME type found for {entity}'s avatar, assuming image/png") .format(entity=entity_jid.full())) if buf[:8] != b'\x89\x50\x4e\x47\x0d\x0a\x1a\x0a': log.warning("this is not a PNG file, ignoring it") raise Failure(exceptions.DataError()) else: mime_type = "image/png" log.debug(_("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(("parsing vcard")) vcard_dict = {} for elem in vcard.elements(): if elem.name == "FN": vcard_dict["fullname"] = str(elem) elif elem.name == "NICKNAME": vcard_dict["nick"] = str(elem) self.updateCache(client, entity_jid, "nick", vcard_dict["nick"]) elif elem.name == "URL": vcard_dict["website"] = str(elem) elif elem.name == "EMAIL": vcard_dict["email"] = str(elem) elif elem.name == "BDAY": vcard_dict["birthday"] = str(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): avatar_hash = "" vcard_dict["avatar"] = avatar_hash except Exception as e: log.error("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("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(list(vcard_dict.keys())): log.debug( "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( "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 next(iq_elt.elements(NS_VCARD, "vCard")) def getCardRaw(self, client, entity_jid): """get raw vCard XML params are as in [getCard] """ entity_jid = self.getBareOrFull(client, entity_jid) log.debug("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, __, 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) # we need to convert the Path to string d.addCallback(str) d.addErrback(lambda __: "") 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 = Path("") 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 __: 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, "nick") if nick is not None: defer.returnValue(nick) yield self.getCard(client, entity) defer.returnValue(self.getCache(client, entity, "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, "NICKNAME")) except StopIteration: pass else: vcard_elt.children.remove(nickname_elt) nickname_elt = vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick) iq_elt = client.IQ() vcard_elt = iq_elt.addChild(vcard_elt) yield iq_elt.send() self.updateCache(client, jid_, "nick", str(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("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 = io.BytesIO() img.save(img_buf, "PNG") photo_elt = vcard_elt.addElement("PHOTO") photo_elt.addElement("TYPE", content="image/png") image_b64 = b64encode(img_buf.getvalue()).decode('utf-8') photo_elt.addElement("BINVAL", content=image_b64) 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, "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" ! @implementer(iwokkel.IDisco) class XEP_0054_handler(XMPPHandler): 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, __, 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( "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 = next(presence.elements(NS_VCARD_UPDATE, "x")) except StopIteration: return try: photo_elt = next(x_elt.elements(NS_VCARD_UPDATE, "photo")) except StopIteration: return hash_ = str(photo_elt).strip() if hash_ == C.HASH_SHA1_EMPTY: hash_ = "" 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( "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("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( "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( "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_)