# HG changeset patch # User Goffi # Date 1656497529 -7200 # Node ID 6329ee6b6df4f281bc2c318cd674dde567041f3d # Parent 5d72dc52ee4ad133ae266e7f6147ebff3cdcd965 component AP: convert AP identity data to XMPP: metadata are converted to vCard4 (XEP-0292). Avatar is converted to User Avatar (XEP-0084), it is downloaded, resized, converted and cached on first request. If avatar is bigger than 5 Mio, it won't be used. rel 368 diff -r 5d72dc52ee4a -r 6329ee6b6df4 sat/plugins/plugin_comp_ap_gateway/__init__.py --- a/sat/plugins/plugin_comp_ap_gateway/__init__.py Wed Jun 29 12:08:53 2022 +0200 +++ b/sat/plugins/plugin_comp_ap_gateway/__init__.py Wed Jun 29 12:12:09 2022 +0200 @@ -50,6 +50,7 @@ from sat.core.i18n import _ from sat.core.log import getLogger from sat.memory.sqla_mapping import SubscriptionState, History +from sat.memory import persistent from sat.tools import utils from sat.tools.common import data_format, tls, uri from sat.tools.common.async_utils import async_lru @@ -105,21 +106,25 @@ class APGateway: + IMPORT_NAME = IMPORT_NAME def __init__(self, host): self.host = host self.initialised = False self.client = None + self._p = host.plugins["XEP-0060"] + self._a = host.plugins["XEP-0084"] + self._e = host.plugins["XEP-0106"] self._m = host.plugins["XEP-0277"] - self._p = host.plugins["XEP-0060"] - self._e = host.plugins["XEP-0106"] + self._v = host.plugins["XEP-0292"] self._r = host.plugins["XEP-0424"] self._pps = host.plugins["XEP-0465"] self._c = host.plugins["PUBSUB_CACHE"] + self._t = host.plugins["TEXT_SYNTAXES"] + self._i = host.plugins["IDENTITY"] self._p.addManagedNode( "", items_cb=self._itemsReceived ) - self._t = host.plugins["TEXT_SYNTAXES"] self.pubsub_service = APPubsubService(self) host.trigger.add("messageReceived", self._messageReceivedTrigger, priority=-1000) host.trigger.add("XEP-0424_retractReceived", self._onMessageRetract) @@ -230,6 +235,10 @@ async def profileConnecting(self, client): self.client = client client.sendHistory = True + client._ap_storage = persistent.LazyPersistentBinaryDict( + IMPORT_NAME, + client.profile + ) await self.init(client) async def _itemsReceived( diff -r 5d72dc52ee4a -r 6329ee6b6df4 sat/plugins/plugin_comp_ap_gateway/constants.py --- a/sat/plugins/plugin_comp_ap_gateway/constants.py Wed Jun 29 12:08:53 2022 +0200 +++ b/sat/plugins/plugin_comp_ap_gateway/constants.py Wed Jun 29 12:12:09 2022 +0200 @@ -74,3 +74,8 @@ ACTIVIY_NO_ACCOUNT_ALLOWED = ("create", "delete") # maximum number of parents to retrieve when comments_max_depth option is set COMMENTS_MAX_PARENTS = 100 +# maximum size of avatar, in bytes +MAX_AVATAR_SIZE = 1024 * 1024 * 5 + +# storage prefixes +ST_AVATAR = "[avatar]" diff -r 5d72dc52ee4a -r 6329ee6b6df4 sat/plugins/plugin_comp_ap_gateway/pubsub_service.py --- a/sat/plugins/plugin_comp_ap_gateway/pubsub_service.py Wed Jun 29 12:08:53 2022 +0200 +++ b/sat/plugins/plugin_comp_ap_gateway/pubsub_service.py Wed Jun 29 12:12:09 2022 +0200 @@ -16,22 +16,31 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Optional, Tuple, List, Union +from typing import Optional, Tuple, List, Dict, Any, Union +from urllib.parse import urlparse +from pathlib import Path +from base64 import b64encode +import tempfile -from twisted.internet import defer +from twisted.internet import defer, threads from twisted.words.protocols.jabber import jid, error from twisted.words.xish import domish from wokkel import rsm, pubsub, disco from sat.core.i18n import _ from sat.core import exceptions +from sat.core.core_types import SatXMPPEntity from sat.core.log import getLogger from sat.core.constants import Const as C +from sat.tools import image from sat.tools.utils import ensure_deferred +from sat.tools.web import downloadFile from sat.memory.sqla_mapping import PubsubSub, SubscriptionState from .constants import ( TYPE_ACTOR, + ST_AVATAR, + MAX_AVATAR_SIZE ) @@ -132,6 +141,139 @@ item_elt = pubsub.Item(id=actor_id, payload=subscriber_elt) return item_elt + async def generateVCard(self, ap_account: str) -> domish.Element: + """Generate vCard4 (XEP-0292) item element from ap_account's metadata""" + actor_data = await self.apg.getAPActorDataFromAccount(ap_account) + identity_data = {} + + summary = actor_data.get("summary") + # summary is HTML, we have to convert it to text + if summary: + identity_data["description"] = await self.apg._t.convert( + summary, + self.apg._t.SYNTAX_XHTML, + self.apg._t.SYNTAX_TEXT, + False, + ) + + for field in ("name", "preferredUsername"): + value = actor_data.get(field) + if value: + identity_data.setdefault("nicknames", []).append(value) + vcard_elt = self.apg._v.dict2VCard(identity_data) + item_elt = domish.Element((pubsub.NS_PUBSUB, "item")) + item_elt.addChild(vcard_elt) + item_elt["id"] = self.apg._p.ID_SINGLETON + return item_elt + + async def getAvatarData( + self, + client: SatXMPPEntity, + ap_account: str + ) -> Dict[str, Any]: + """Retrieve actor's avatar if any, cache it and file actor_data + + ``cache_uid``, `path``` and ``media_type`` keys are always files + ``base64`` key is only filled if the file was not already in cache + """ + actor_data = await self.apg.getAPActorDataFromAccount(ap_account) + + for icon in await self.apg.apGetList(actor_data, "icon"): + url = icon.get("url") + if icon["type"] != "Image" or not url: + continue + parsed_url = urlparse(url) + if not parsed_url.scheme in ("http", "https"): + log.warning(f"unexpected URL scheme: {url!r}") + continue + filename = Path(parsed_url.path).name + if not filename: + log.warning(f"ignoring URL with invald path: {url!r}") + continue + break + else: + raise error.StanzaError("item-not-found") + + key = f"{ST_AVATAR}{url}" + cache_uid = await client._ap_storage.get(key) + + if cache_uid is None: + cache = None + else: + cache = self.apg.host.common_cache.getMetadata(cache_uid) + + if cache is None: + with tempfile.TemporaryDirectory() as dir_name: + dest_path = Path(dir_name, filename) + await downloadFile(url, dest_path, max_size=MAX_AVATAR_SIZE) + avatar_data = { + "path": dest_path, + "filename": filename, + 'media_type': image.guess_type(dest_path), + } + + await self.apg._i.cacheAvatar( + self.apg.IMPORT_NAME, + avatar_data + ) + else: + avatar_data = { + "cache_uid": cache["uid"], + "path": cache["path"], + "media_type": cache["mime_type"] + } + + return avatar_data + + async def generateAvatarMetadata( + self, + client: SatXMPPEntity, + ap_account: str + ) -> domish.Element: + """Generate the metadata element for user avatar + + @raise StanzaError("item-not-found"): no avatar is present in actor data (in + ``icon`` field) + """ + avatar_data = await self.getAvatarData(client, ap_account) + return self.apg._a.buildItemMetadataElt(avatar_data) + + def _blockingB64EncodeAvatar(self, avatar_data: Dict[str, Any]) -> None: + with avatar_data["path"].open("rb") as f: + avatar_data["base64"] = b64encode(f.read()).decode() + + async def generateAvatarData( + self, + client: SatXMPPEntity, + ap_account: str, + itemIdentifiers: Optional[List[str]], + ) -> domish.Element: + """Generate the data element for user avatar + + @raise StanzaError("item-not-found"): no avatar cached with requested ID + """ + if not itemIdentifiers: + avatar_data = await self.getAvatarData(client, ap_account) + if "base64" not in avatar_data: + await threads.deferToThread(self._blockingB64EncodeAvatar, avatar_data) + else: + if len(itemIdentifiers) > 1: + # only a single item ID is supported + raise error.StanzaError("item-not-found") + item_id = itemIdentifiers[0] + # just to be sure that that we don't have an empty string + assert item_id + cache_data = self.apg.host.common_cache.getMetadata(item_id) + if cache_data is None: + raise error.StanzaError("item-not-found") + avatar_data = { + "cache_uid": item_id, + "path": cache_data["path"] + } + await threads.deferToThread(self._blockingB64EncodeAvatar, avatar_data) + + return self.apg._a.buildItemDataElt(avatar_data) + @ensure_deferred async def items( self, @@ -161,6 +303,18 @@ parser = self.apFollower2Elt kwargs["only_ids"] = True use_cache = False + elif node == self.apg._v.node: + # vCard4 request + item_elt = await self.generateVCard(ap_account) + return [item_elt], None + elif node == self.apg._a.namespace_metadata: + item_elt = await self.generateAvatarMetadata(self.apg.client, ap_account) + return [item_elt], None + elif node == self.apg._a.namespace_data: + item_elt = await self.generateAvatarData( + self.apg.client, ap_account, itemIdentifiers + ) + return [item_elt], None else: if not node.startswith(self.apg._m.namespace): raise error.StanzaError(