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
This commit is contained in:
Goffi 2022-06-29 12:12:09 +02:00
commit 68c627570d
3 changed files with 172 additions and 4 deletions

View file

@ -50,6 +50,7 @@ from sat.core.core_types import SatXMPPEntity
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 @@ RE_ALLOWED_UNQUOTED = re.compile(r"^[a-zA-Z0-9_-]+$")
class APGateway:
IMPORT_NAME = IMPORT_NAME
def __init__(self, host):
self.host = host
self.initialised = False
self.client = None
self._m = host.plugins["XEP-0277"]
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._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 @@ class APGateway:
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(

View file

@ -74,3 +74,8 @@ ACTIVITY_TARGET_MANDATORY = ("Add", "Remove")
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]"

View file

@ -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 @@ class APPubsubService(rsm.PubSubService):
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 @@ class APPubsubService(rsm.PubSubService):
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(