historically, Libervia (SàT before) was using camelCase as allowed by PEP8 when using a pre-PEP8 code, to use the same coding style as in Twisted. However, snake_case is more readable and it's better to follow PEP8 best practices, so it has been decided to move on full snake_case. Because Libervia has a huge codebase, this ended with a ugly mix of camelCase and snake_case. To fix that, this patch does a big refactoring by renaming every function and method (including bridge) that are not coming from Twisted or Wokkel, to use fully snake_case. This is a massive change, and may result in some bugs.
475 lines
17 KiB
Python
475 lines
17 KiB
Python
#!/usr/bin/env python3
|
|
|
|
# SAT plugin for managing xep-0054
|
|
# Copyright (C) 2009-2021 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 io
|
|
from base64 import b64decode, b64encode
|
|
from hashlib import sha1
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
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.core.xmpp import SatXMPPEntity
|
|
from sat.memory import persistent
|
|
from sat.tools import image
|
|
|
|
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"
|
|
)
|
|
|
|
from twisted.words.protocols.jabber.xmlstream import XMPPHandler
|
|
|
|
IMPORT_NAME = "XEP-0054"
|
|
|
|
PLUGIN_INFO = {
|
|
C.PI_NAME: "XEP 0054 Plugin",
|
|
C.PI_IMPORT_NAME: IMPORT_NAME,
|
|
C.PI_TYPE: "XEP",
|
|
C.PI_MODES: C.PLUG_MODE_BOTH,
|
|
C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"],
|
|
C.PI_DEPENDENCIES: ["IDENTITY"],
|
|
C.PI_RECOMMENDATIONS: [],
|
|
C.PI_MAIN: "XEP_0054",
|
|
C.PI_HANDLER: "yes",
|
|
C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""),
|
|
}
|
|
|
|
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 + '"]'
|
|
|
|
HASH_SHA1_EMPTY = "da39a3ee5e6b4b0d3255bfef95601890afd80709"
|
|
|
|
|
|
class XEP_0054(object):
|
|
|
|
def __init__(self, host):
|
|
log.info(_("Plugin XEP_0054 initialization"))
|
|
self.host = host
|
|
self._i = host.plugins['IDENTITY']
|
|
self._i.register(IMPORT_NAME, 'avatar', self.get_avatar, self.set_avatar)
|
|
self._i.register(IMPORT_NAME, 'nicknames', self.get_nicknames, self.set_nicknames)
|
|
host.trigger.add("presence_available", self.presence_available_trigger)
|
|
|
|
def get_handler(self, client):
|
|
return XEP_0054_handler(self)
|
|
|
|
def presence_available_trigger(self, presence_elt, client):
|
|
try:
|
|
avatar_hash = client._xep_0054_avatar_hashes[client.jid.userhost()]
|
|
except KeyError:
|
|
log.info(
|
|
_("No avatar in cache for {profile}")
|
|
.format(profile=client.profile))
|
|
return True
|
|
x_elt = domish.Element((NS_VCARD_UPDATE, "x"))
|
|
x_elt.addElement("photo", content=avatar_hash)
|
|
presence_elt.addChild(x_elt)
|
|
return True
|
|
|
|
async def profile_connecting(self, client):
|
|
client._xep_0054_avatar_hashes = persistent.PersistentDict(
|
|
NS_VCARD, client.profile)
|
|
await client._xep_0054_avatar_hashes.load()
|
|
|
|
def save_photo(self, client, photo_elt, entity):
|
|
"""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 known, we'll try autodetection below
|
|
mime_type = None
|
|
elif mime_type == "image/x-png":
|
|
# XXX: this old MIME type is still used by some clients
|
|
mime_type = "image/png"
|
|
|
|
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.full()))
|
|
raise Failure(exceptions.NotFound())
|
|
|
|
log.debug(_("Decoding binary"))
|
|
decoded = b64decode(buf)
|
|
del buf
|
|
|
|
if mime_type is None:
|
|
log.debug(
|
|
f"no media type found specified for {entity}'s avatar, trying to "
|
|
f"guess")
|
|
|
|
try:
|
|
mime_type = image.guess_type(io.BytesIO(decoded))
|
|
except IOError as e:
|
|
log.warning(f"Can't open avatar buffer: {e}")
|
|
|
|
if mime_type is None:
|
|
msg = f"Can't find media type for {entity}'s avatar"
|
|
log.warning(msg)
|
|
raise Failure(exceptions.DataError(msg))
|
|
|
|
image_hash = sha1(decoded).hexdigest()
|
|
with self.host.common_cache.cache_data(
|
|
PLUGIN_INFO["import_name"],
|
|
image_hash,
|
|
mime_type,
|
|
) as f:
|
|
f.write(decoded)
|
|
return image_hash
|
|
|
|
async def v_card_2_dict(self, client, vcard_elt, entity_jid):
|
|
"""Convert a VCard_elt to a dict, and save binaries"""
|
|
log.debug(("parsing vcard_elt"))
|
|
vcard_dict = {}
|
|
|
|
for elem in vcard_elt.elements():
|
|
if elem.name == "FN":
|
|
vcard_dict["fullname"] = str(elem)
|
|
elif elem.name == "NICKNAME":
|
|
nickname = vcard_dict["nickname"] = str(elem)
|
|
await self._i.update(
|
|
client,
|
|
IMPORT_NAME,
|
|
"nicknames",
|
|
[nickname],
|
|
entity_jid
|
|
)
|
|
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 = await threads.deferToThread(
|
|
self.save_photo, client, elem, entity_jid
|
|
)
|
|
except (exceptions.DataError, exceptions.NotFound):
|
|
avatar_hash = ""
|
|
vcard_dict["avatar"] = avatar_hash
|
|
except Exception as e:
|
|
log.error(f"avatar saving error: {e}")
|
|
avatar_hash = None
|
|
else:
|
|
vcard_dict["avatar"] = avatar_hash
|
|
if avatar_hash is not None:
|
|
await client._xep_0054_avatar_hashes.aset(
|
|
entity_jid.full(), avatar_hash)
|
|
|
|
if avatar_hash:
|
|
avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
|
|
await self._i.update(
|
|
client,
|
|
IMPORT_NAME,
|
|
"avatar",
|
|
{
|
|
'path': avatar_cache['path'],
|
|
'filename': avatar_cache['filename'],
|
|
'media_type': avatar_cache['mime_type'],
|
|
'cache_uid': avatar_hash
|
|
},
|
|
entity_jid
|
|
)
|
|
else:
|
|
await self._i.update(
|
|
client, IMPORT_NAME, "avatar", None, entity_jid)
|
|
else:
|
|
log.debug("FIXME: [{}] VCard_elt tag is not managed yet".format(elem.name))
|
|
|
|
return vcard_dict
|
|
|
|
async def get_vcard_element(self, client, entity_jid):
|
|
"""Retrieve domish.Element of a VCard
|
|
|
|
@param entity_jid(jid.JID): entity from who we need the vCard
|
|
@raise DataError: we got an invalid answer
|
|
"""
|
|
iq_elt = client.IQ("get")
|
|
iq_elt["from"] = client.jid.full()
|
|
iq_elt["to"] = entity_jid.full()
|
|
iq_elt.addElement("vCard", NS_VCARD)
|
|
iq_ret_elt = await iq_elt.send(entity_jid.full())
|
|
try:
|
|
return next(iq_ret_elt.elements(NS_VCARD, "vCard"))
|
|
except StopIteration:
|
|
log.warning(_(
|
|
"vCard element not found for {entity_jid}: {xml}"
|
|
).format(entity_jid=entity_jid, xml=iq_ret_elt.toXml()))
|
|
raise exceptions.DataError(f"no vCard element found for {entity_jid}")
|
|
|
|
async def update_vcard_elt(self, client, entity_jid, to_replace):
|
|
"""Create a vcard element to replace some metadata
|
|
|
|
@param to_replace(list[str]): list of vcard element names to remove
|
|
"""
|
|
try:
|
|
# we first check if a vcard already exists, to keep data
|
|
vcard_elt = await self.get_vcard_element(client, entity_jid)
|
|
except error.StanzaError as e:
|
|
if e.condition == "item-not-found":
|
|
vcard_elt = domish.Element((NS_VCARD, "vCard"))
|
|
else:
|
|
raise e
|
|
except exceptions.DataError:
|
|
vcard_elt = domish.Element((NS_VCARD, "vCard"))
|
|
else:
|
|
# the vcard exists, we need to remove elements that we'll replace
|
|
for elt_name in to_replace:
|
|
try:
|
|
elt = next(vcard_elt.elements(NS_VCARD, elt_name))
|
|
except StopIteration:
|
|
pass
|
|
else:
|
|
vcard_elt.children.remove(elt)
|
|
|
|
return vcard_elt
|
|
|
|
async def get_card(self, client, entity_jid):
|
|
"""Ask server for VCard
|
|
|
|
@param entity_jid(jid.JID): jid from which we want the VCard
|
|
@result(dict): vCard data
|
|
"""
|
|
entity_jid = self._i.get_identity_jid(client, entity_jid)
|
|
log.debug(f"Asking for {entity_jid}'s VCard")
|
|
try:
|
|
vcard_elt = await self.get_vcard_element(client, entity_jid)
|
|
except exceptions.DataError:
|
|
self._i.update(client, IMPORT_NAME, "avatar", None, entity_jid)
|
|
except Exception as e:
|
|
log.warning(_(
|
|
"Can't get vCard for {entity_jid}: {e}"
|
|
).format(entity_jid=entity_jid, e=e))
|
|
else:
|
|
log.debug(_("VCard found"))
|
|
return await self.v_card_2_dict(client, vcard_elt, entity_jid)
|
|
|
|
async def get_avatar(
|
|
self,
|
|
client: SatXMPPEntity,
|
|
entity_jid: jid.JID
|
|
) -> Optional[dict]:
|
|
"""Get avatar data
|
|
|
|
@param entity: entity to get avatar from
|
|
@return: avatar metadata, or None if no avatar has been found
|
|
"""
|
|
entity_jid = self._i.get_identity_jid(client, entity_jid)
|
|
hashes_cache = client._xep_0054_avatar_hashes
|
|
vcard = await self.get_card(client, entity_jid)
|
|
if vcard is None:
|
|
return None
|
|
try:
|
|
avatar_hash = hashes_cache[entity_jid.full()]
|
|
except KeyError:
|
|
if 'avatar' in vcard:
|
|
raise exceptions.InternalError(
|
|
"No avatar hash while avatar is found in vcard")
|
|
return None
|
|
|
|
if not avatar_hash:
|
|
return None
|
|
|
|
avatar_cache = self.host.common_cache.get_metadata(avatar_hash)
|
|
return self._i.avatar_build_metadata(
|
|
avatar_cache['path'], avatar_cache['mime_type'], avatar_hash)
|
|
|
|
async def set_avatar(self, client, avatar_data, entity):
|
|
"""Set avatar of the profile
|
|
|
|
@param avatar_data(dict): data of the image to use as avatar, as built by
|
|
IDENTITY plugin.
|
|
@param entity(jid.JID): entity whose avatar must be changed
|
|
"""
|
|
vcard_elt = await self.update_vcard_elt(client, entity, ['PHOTO'])
|
|
|
|
iq_elt = client.IQ()
|
|
iq_elt.addChild(vcard_elt)
|
|
# metadata with encoded image are now filled at the right size/format
|
|
photo_elt = vcard_elt.addElement("PHOTO")
|
|
photo_elt.addElement("TYPE", content=avatar_data["media_type"])
|
|
photo_elt.addElement("BINVAL", content=avatar_data["base64"])
|
|
|
|
await iq_elt.send()
|
|
|
|
# FIXME: should send the current presence, not always "available" !
|
|
await client.presence.available()
|
|
|
|
async def get_nicknames(self, client, entity):
|
|
"""get nick from cache, or check vCard
|
|
|
|
@param entity(jid.JID): entity to get nick from
|
|
@return(list[str]): nicknames found
|
|
"""
|
|
vcard_data = await self.get_card(client, entity)
|
|
try:
|
|
return [vcard_data['nickname']]
|
|
except (KeyError, TypeError):
|
|
return []
|
|
|
|
async def set_nicknames(self, client, nicknames, entity):
|
|
"""Update our vCard and set a nickname
|
|
|
|
@param nicknames(list[str]): new nicknames to use
|
|
only first item of this list will be used here
|
|
"""
|
|
nick = nicknames[0].strip()
|
|
|
|
vcard_elt = await self.update_vcard_elt(client, entity, ['NICKNAME'])
|
|
|
|
if nick:
|
|
vcard_elt.addElement((NS_VCARD, "NICKNAME"), content=nick)
|
|
iq_elt = client.IQ()
|
|
iq_elt.addChild(vcard_elt)
|
|
await iq_elt.send()
|
|
|
|
|
|
@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 []
|
|
|
|
async def update(self, presence):
|
|
"""Called on <presence/> stanza with vcard data
|
|
|
|
Check for avatar information, and get VCard if needed
|
|
@param presence(domish.Element): <presence/> stanza
|
|
"""
|
|
client = self.parent
|
|
entity_jid = self.plugin_parent._i.get_identity_jid(
|
|
client, jid.JID(presence["from"]))
|
|
|
|
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
|
|
|
|
given_hash = str(photo_elt).strip()
|
|
if given_hash == HASH_SHA1_EMPTY:
|
|
given_hash = ""
|
|
|
|
hashes_cache = client._xep_0054_avatar_hashes
|
|
|
|
old_hash = hashes_cache.get(entity_jid.full())
|
|
|
|
if old_hash == given_hash:
|
|
# no change, we can return…
|
|
if given_hash:
|
|
# …but we double check that avatar is in cache
|
|
avatar_cache = self.host.common_cache.get_metadata(given_hash)
|
|
if avatar_cache is None:
|
|
log.debug(
|
|
f"Avatar for [{entity_jid}] is known but not in cache, we get "
|
|
f"it"
|
|
)
|
|
# get_card will put the avatar in cache
|
|
await self.plugin_parent.get_card(client, entity_jid)
|
|
else:
|
|
log.debug(f"avatar for {entity_jid} is already in cache")
|
|
return
|
|
|
|
if given_hash is None:
|
|
# XXX: we use empty string to indicate that there is no avatar
|
|
given_hash = ""
|
|
|
|
await hashes_cache.aset(entity_jid.full(), given_hash)
|
|
|
|
if not given_hash:
|
|
await self.plugin_parent._i.update(
|
|
client, IMPORT_NAME, "avatar", None, entity_jid)
|
|
# the avatar has been removed, no need to go further
|
|
return
|
|
|
|
avatar_cache = self.host.common_cache.get_metadata(given_hash)
|
|
if avatar_cache is not None:
|
|
log.debug(
|
|
f"New avatar found for [{entity_jid}], it's already in cache, we use it"
|
|
)
|
|
await self.plugin_parent._i.update(
|
|
client,
|
|
IMPORT_NAME, "avatar",
|
|
{
|
|
'path': avatar_cache['path'],
|
|
'filename': avatar_cache['filename'],
|
|
'media_type': avatar_cache['mime_type'],
|
|
'cache_uid': given_hash,
|
|
},
|
|
entity_jid
|
|
)
|
|
else:
|
|
log.debug(
|
|
"New avatar found for [{entity_jid}], requesting vcard"
|
|
)
|
|
vcard = await self.plugin_parent.get_card(client, entity_jid)
|
|
if vcard is None:
|
|
log.warning(f"Unexpected empty vCard for {entity_jid}")
|
|
return
|
|
computed_hash = client._xep_0054_avatar_hashes[entity_jid.full()]
|
|
if computed_hash != given_hash:
|
|
log.warning(
|
|
"computed hash differs from given hash for {entity}:\n"
|
|
"computed: {computed}\ngiven: {given}".format(
|
|
entity=entity_jid, computed=computed_hash, given=given_hash
|
|
)
|
|
)
|
|
|
|
def _update(self, presence):
|
|
defer.ensureDeferred(self.update(presence))
|