# HG changeset patch # User Goffi # Date 1656496747 -7200 # Node ID 88e332cec47b644e02d9ef4cfa2df581b6bb1336 # Parent 4f02e339d184a82b4aa40d5300c374c259a505cc plugin XEP-0084: "User Avatar" implementation: rel 368 diff -r 4f02e339d184 -r 88e332cec47b sat/plugins/plugin_xep_0084.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat/plugins/plugin_xep_0084.py Wed Jun 29 11:59:07 2022 +0200 @@ -0,0 +1,268 @@ +#!/usr/bin/env python3 + +# Libervia plugin for XEP-0084 +# Copyright (C) 2009-2022 Jérôme Poisson (goffi@goffi.org) + +# 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 . + +from typing import Optional, Dict, Any +from pathlib import Path +from base64 import b64decode, b64encode + +from twisted.internet import defer +from twisted.words.protocols.jabber.xmlstream import XMPPHandler +from twisted.words.protocols.jabber import jid, error +from twisted.words.xish import domish +from zope.interface import implementer +from wokkel import disco, iwokkel, pubsub + +from sat.core.constants import Const as C +from sat.core.i18n import _ +from sat.core.log import getLogger +from sat.core.core_types import SatXMPPEntity +from sat.core import exceptions + + +log = getLogger(__name__) + +IMPORT_NAME = "XEP-0084" + +PLUGIN_INFO = { + C.PI_NAME: "User Avatar", + C.PI_IMPORT_NAME: IMPORT_NAME, + C.PI_TYPE: C.PLUG_TYPE_XEP, + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: ["XEP-0084"], + C.PI_DEPENDENCIES: ["IDENTITY", "XEP-0060", "XEP-0163"], + C.PI_MAIN: "XEP_0084", + C.PI_HANDLER: "yes", + C.PI_DESCRIPTION: _("""XEP-0084 (User Avatar) implementation"""), +} + +NS_AVATAR = "urn:xmpp:avatar" +NS_AVATAR_METADATA = f"{NS_AVATAR}:metadata" +NS_AVATAR_DATA = f"{NS_AVATAR}:data" + + +class XEP_0084: + namespace_metadata = NS_AVATAR_METADATA + namespace_data = NS_AVATAR_DATA + + def __init__(self, host): + log.info(_("XEP-0084 (User Avatar) plugin initialization")) + host.registerNamespace("avatar_metadata", NS_AVATAR_METADATA) + host.registerNamespace("avatar_data", NS_AVATAR_DATA) + self.host = host + self._p = host.plugins["XEP-0060"] + self._i = host.plugins['IDENTITY'] + self._i.register( + IMPORT_NAME, + "avatar", + self.getAvatar, + self.setAvatar, + priority=2000 + ) + host.plugins["XEP-0163"].addPEPEvent( + None, NS_AVATAR_METADATA, self._onMetadataUpdate + ) + + def getHandler(self, client): + return XEP_0084_Handler() + + def _onMetadataUpdate(self, itemsEvent, profile): + client = self.host.getClient(profile) + defer.ensureDeferred(self.onMetadataUpdate(client, itemsEvent)) + + async def onMetadataUpdate( + self, + client: SatXMPPEntity, + itemsEvent: pubsub.ItemsEvent + ) -> None: + entity = client.jid.userhostJID() + avatar_metadata = await self.getAvatar(client, entity) + await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity) + + async def getAvatar( + 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 + """ + service = entity_jid.userhostJID() + # metadata + try: + items, __ = await self._p.getItems( + client, + service, + NS_AVATAR_METADATA, + max_items=1 + ) + except exceptions.NotFound: + return None + + if not items: + return None + + item_elt = items[0] + try: + metadata_elt = next(item_elt.elements(NS_AVATAR_METADATA, "metadata")) + except StopIteration: + log.warning(f"missing metadata element: {item_elt.toXml()}") + return None + + for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"): + try: + metadata = { + "id": str(info_elt["id"]), + "size": int(info_elt["bytes"]), + "media_type": str(info_elt["type"]) + } + avatar_id = metadata["id"] + if not avatar_id: + raise ValueError + except (KeyError, ValueError): + log.warning(f"invalid element: {item_elt.toXml()}") + return None + # FIXME: to simplify, we only handle image/png for now + if metadata["media_type"] == "image/png": + break + else: + # mandatory image/png is missing, or avatar is disabled + # (https://xmpp.org/extensions/xep-0084.html#pub-disable) + return None + + cache_data = self.host.common_cache.getMetadata(avatar_id) + if not cache_data: + try: + data_items, __ = await self._p.getItems( + client, + service, + NS_AVATAR_DATA, + item_ids=[avatar_id] + ) + data_item_elt = data_items[0] + except (error.StanzaError, IndexError) as e: + log.warning( + f"Can't retrieve avatar of {service.full()} with ID {avatar_id!r}: " + f"{e}" + ) + return None + try: + avatar_buf = b64decode( + str(next(data_item_elt.elements(NS_AVATAR_DATA, "data"))) + ) + except Exception as e: + log.warning( + f"invalid data element for {service.full()} with avatar ID " + f"{avatar_id!r}: {e}\n{data_item_elt.toXml()}" + ) + return None + with self.host.common_cache.cacheData( + IMPORT_NAME, + avatar_id, + metadata["media_type"] + ) as f: + f.write(avatar_buf) + cache_data = { + "path": Path(f.name), + "mime_type": metadata["media_type"] + } + + return self._i.avatarBuildMetadata( + cache_data['path'], cache_data['mime_type'], avatar_id + ) + + def buildItemDataElt(self, avatar_data: Dict[str, Any]) -> domish.Element: + """Generate the item for the data node + + @param avatar_data: data as build by identity plugin (need to be filled with + "cache_uid" and "base64" keys) + """ + data_elt = domish.Element((NS_AVATAR_DATA, "data")) + data_elt.addContent(avatar_data["base64"]) + return pubsub.Item(id=avatar_data["cache_uid"], payload=data_elt) + + def buildItemMetadataElt(self, avatar_data: Dict[str, Any]) -> domish.Element: + """Generate the item for the metadata node + + @param avatar_data: data as build by identity plugin (need to be filled with + "cache_uid", "path", and "media_type" keys) + """ + metadata_elt = domish.Element((NS_AVATAR_METADATA, "metadata")) + info_elt = metadata_elt.addElement("info") + # FIXME: we only fill required elements for now (see + # https://xmpp.org/extensions/xep-0084.html#table-1) + info_elt["id"] = avatar_data["cache_uid"] + info_elt["type"] = avatar_data["media_type"] + info_elt["bytes"] = str(avatar_data["path"].stat().st_size) + return pubsub.Item(id=self._p.ID_SINGLETON, payload=metadata_elt) + + async def setAvatar( + self, + client: SatXMPPEntity, + avatar_data: Dict[str, Any], + entity: jid.JID + ) -> None: + """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 + """ + service = entity.userhostJID() + + # Data + await self._p.createIfNewNode( + client, + service, + NS_AVATAR_DATA, + options={ + self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN, + self._p.OPT_PERSIST_ITEMS: 1, + self._p.OPT_MAX_ITEMS: 1, + } + ) + item_data_elt = self.buildItemDataElt(avatar_data) + await self._p.sendItems(client, service, NS_AVATAR_DATA, [item_data_elt]) + + # Metadata + await self._p.createIfNewNode( + client, + service, + NS_AVATAR_METADATA, + options={ + self._p.OPT_ACCESS_MODEL: self._p.ACCESS_OPEN, + self._p.OPT_PERSIST_ITEMS: 1, + self._p.OPT_MAX_ITEMS: 1, + } + ) + item_metadata_elt = self.buildItemMetadataElt(avatar_data) + await self._p.sendItems(client, service, NS_AVATAR_METADATA, [item_metadata_elt]) + + +@implementer(iwokkel.IDisco) +class XEP_0084_Handler(XMPPHandler): + + def getDiscoInfo(self, requestor, service, nodeIdentifier=""): + return [ + disco.DiscoFeature(NS_AVATAR_METADATA), + disco.DiscoFeature(NS_AVATAR_DATA) + ] + + def getDiscoItems(self, requestor, service, nodeIdentifier=""): + return []