From 709366fe1367c42d74c55e1ee3a726b851b2e454 Mon Sep 17 00:00:00 2001 From: Goffi Date: Mon, 4 May 2026 16:49:07 +0200 Subject: [PATCH] plugin XEP-0084: fix avatar retrieval and disabling: - Avatar disabling was not correctly handled for all cases, this should now be fixed. - Avatar retrieval is now down only on PEP events, no directly anymore. `get_avatar` only returns cached version. --- libervia/backend/plugins/plugin_xep_0084.py | 153 +++++++++++++------- 1 file changed, 103 insertions(+), 50 deletions(-) diff --git a/libervia/backend/plugins/plugin_xep_0084.py b/libervia/backend/plugins/plugin_xep_0084.py index 0cef86c4..53fffcf8 100644 --- a/libervia/backend/plugins/plugin_xep_0084.py +++ b/libervia/backend/plugins/plugin_xep_0084.py @@ -16,9 +16,10 @@ # 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 typing import Any, Dict + +from base64 import b64decode from pathlib import Path -from base64 import b64decode, b64encode from twisted.internet import defer from twisted.words.protocols.jabber.xmlstream import XMPPHandler @@ -82,36 +83,28 @@ class XEP_0084: async def on_metadata_update( self, client: SatXMPPEntity, itemsEvent: pubsub.ItemsEvent ) -> None: - entity = itemsEvent.sender - avatar_metadata = await self.get_avatar(client, entity) - await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity) + """Handle PEP avatar metadata update event. - 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 + Parse metadata, fetch data if not cached, and update identity cache. """ - service = entity_jid.userhostJID() - # metadata - try: - items, __ = await self._p.get_items( - client, service, NS_AVATAR_METADATA, max_items=1 - ) - except exceptions.NotFound: - return None + entity = itemsEvent.sender + service = entity.userhostJID() + items = itemsEvent.items if not items: - return None + return + + if items[0].name == "retract": + log.debug(f"Avatar retracted for {entity.full()}") + await self._i.update(client, IMPORT_NAME, "avatar", None, entity) + return 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 + return for info_elt in metadata_elt.elements(NS_AVATAR_METADATA, "info"): try: @@ -132,40 +125,100 @@ class XEP_0084: else: # mandatory image/png is missing, or avatar is disabled # (https://xmpp.org/extensions/xep-0084.html#pub-disable) - return None + log.debug(f"Avatar disabled for {entity.full()}") + await self._i.update(client, IMPORT_NAME, "avatar", None, entity) + return + # Check if avatar is already cached + try: + cached = self.host.memory.get_entity_datum(client, entity, "avatar") + except (KeyError, exceptions.UnknownEntityError): + cached = None + + if cached is not None: + cached_uid = cached.get("cache_uid") if isinstance(cached, dict) else None + if cached_uid == avatar_id: + log.debug(f"Avatar already cached for {entity.full()} (id={avatar_id})") + return + log.debug( + f"Avatar changed for {entity.full()} (old={cached_uid}, new={avatar_id})" + ) + + # Fetch avatar data if not already cached cache_data = self.host.common_cache.get_metadata(avatar_id) if not cache_data: - try: - data_items, __ = await self._p.get_items( - 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.cache_data( - IMPORT_NAME, avatar_id, metadata["media_type"] - ) as f: - f.write(avatar_buf) - cache_data = {"path": Path(f.name), "mime_type": metadata["media_type"]} + cache_data = await self._fetch_avatar_data( + client, service, avatar_id, metadata["media_type"] + ) + if cache_data is None: + log.warning(f"Failed to fetch avatar data for {entity.full()}") + return - return self._i.avatar_build_metadata( + log.debug(f"Avatar updated for {entity.full()} (id={avatar_id})") + avatar_metadata = self._i.avatar_build_metadata( cache_data["path"], cache_data["mime_type"], avatar_id ) + await self._i.update(client, IMPORT_NAME, "avatar", avatar_metadata, entity) + + async def _fetch_avatar_data( + self, + client: SatXMPPEntity, + service: jid.JID, + avatar_id: str, + media_type: str, + ) -> dict | None: + """Fetch avatar data from PEP data node and cache it. + + @return: Cache data dict with path and mime_type, or None on failure. + """ + try: + data_items, __ = await self._p.get_items( + 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.cache_data(IMPORT_NAME, avatar_id, media_type) as f: + f.write(avatar_buf) + return {"path": Path(f.name), "mime_type": media_type} + + async def get_avatar(self, client: SatXMPPEntity, entity_jid: jid.JID) -> dict | None: + """Get avatar data from cache.""" + try: + cached = self.host.memory.get_entity_datum(client, entity_jid, "avatar") + except (KeyError, exceptions.UnknownEntityError): + return None + + if not cached: + return None + + cache_uid = cached.get("cache_uid") + filename = cached.get("filename") + if not cache_uid or not filename: + return None + + try: + self.host.common_cache.get_metadata(cache_uid) + except Exception: + return None + + return cached def build_item_data_elt(self, avatar_data: Dict[str, Any]) -> domish.Element: """Generate the item for the data node