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:
parent
1d5e64a6b7
commit
68c627570d
3 changed files with 172 additions and 4 deletions
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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]"
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue