changeset 3824:6329ee6b6df4

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
author Goffi <goffi@goffi.org>
date Wed, 29 Jun 2022 12:12:09 +0200
parents 5d72dc52ee4a
children 10a4846818e5
files sat/plugins/plugin_comp_ap_gateway/__init__.py sat/plugins/plugin_comp_ap_gateway/constants.py sat/plugins/plugin_comp_ap_gateway/pubsub_service.py
diffstat 3 files changed, 173 insertions(+), 5 deletions(-) [+]
line wrap: on
line diff
--- 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(
--- 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]"
--- 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 <http://www.gnu.org/licenses/>.
 
-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(