# HG changeset patch # User Goffi # Date 1656496068 -7200 # Node ID 213e83a4ed10df5ef434d42036e4418cf2c81b40 # Parent 853cbaf56e9e313ab7d06fa32dc1b5f79068d738 plugin identity, XEP-0054: move avatar resizing and caching method to identity plugin: resizing and caching is now done in identity plugin, to prepare for the implementation of other XEP to handle avatars. rel 368 diff -r 853cbaf56e9e -r 213e83a4ed10 sat/plugins/plugin_misc_identity.py --- a/sat/plugins/plugin_misc_identity.py Wed Jun 29 11:36:31 2022 +0200 +++ b/sat/plugins/plugin_misc_identity.py Wed Jun 29 11:47:48 2022 +0200 @@ -15,21 +15,34 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -from typing import Dict, List, Union, Coroutine, Any, Optional from collections import namedtuple +import io from pathlib import Path -from twisted.internet import defer +from base64 import b64encode +import hashlib +from typing import Any, Coroutine, Dict, List, Optional, Union + +from twisted.internet import defer, threads from twisted.words.protocols.jabber import jid -from sat.core.xmpp import SatXMPPEntity -from sat.core.i18n import _ + +from sat.core import exceptions from sat.core.constants import Const as C -from sat.core import exceptions +from sat.core.i18n import _ from sat.core.log import getLogger +from sat.core.xmpp import SatXMPPEntity from sat.memory import persistent from sat.tools import image from sat.tools import utils from sat.tools.common import data_format +try: + from PIL import Image +except: + raise exceptions.MissingModule( + "Missing module pillow, please download/install it from https://python-pillow.github.io" + ) + + log = getLogger(__name__) @@ -50,6 +63,7 @@ } Callback = namedtuple("Callback", ("origin", "get", "set", "priority")) +AVATAR_DIM = (128, 128) class Identity: @@ -474,6 +488,68 @@ return defer.ensureDeferred( self.set(client, "avatar", file_path, entity)) + def _blockingCacheAvatar( + self, + source: str, + avatar_data: dict[str, Any] + ): + """This method is executed in a separated thread""" + if avatar_data["media_type"] == "image/svg+xml": + # for vector image, we save directly + img_buf = open(avatar_data["path"], "rb") + else: + # for bitmap image, we check size and resize if necessary + try: + img = Image.open(avatar_data["path"]) + except IOError as e: + raise exceptions.DataError(f"Can't open image: {e}") + + if img.size != AVATAR_DIM: + img.thumbnail(AVATAR_DIM) + if img.size[0] != img.size[1]: # we need to crop first + left, upper = (0, 0) + right, lower = img.size + offset = abs(right - lower) / 2 + if right == min(img.size): + upper += offset + lower -= offset + else: + left += offset + right -= offset + img = img.crop((left, upper, right, lower)) + img_buf = io.BytesIO() + # PNG is well supported among clients, so we convert to this format + img.save(img_buf, "PNG") + img_buf.seek(0) + avatar_data["media_type"] = "image/png" + + media_type = avatar_data["media_type"] + avatar_data["base64"] = image_b64 = b64encode(img_buf.read()).decode() + img_buf.seek(0) + image_hash = hashlib.sha1(img_buf.read()).hexdigest() + img_buf.seek(0) + with self.host.common_cache.cacheData( + source, image_hash, media_type + ) as f: + f.write(img_buf.read()) + avatar_data['path'] = Path(f.name) + avatar_data['filename'] = avatar_data['path'].name + avatar_data['cache_uid'] = image_hash + + async def cacheAvatar(self, source: str, avatar_data: Dict[str, Any]) -> None: + """Resize if necessary and cache avatar + + @param source: source importing the avatar (usually it is plugin's import name), + will be used in cache metadata + @param avatar_data: avatar metadata as build by [avatarSetDataFilter] + will be updated with following keys: + path: updated path using cached file + filename: updated filename using cached file + base64: resized and base64 encoded avatar + cache_uid: SHA1 hash used as cache unique ID + """ + await threads.deferToThread(self._blockingCacheAvatar, source, avatar_data) + async def avatarSetDataFilter(self, client, entity, file_path): """Convert avatar file path to dict data""" file_path = Path(file_path) @@ -489,13 +565,19 @@ raise ValueError(f"Can't identify type of image at {file_path}") if not media_type.startswith('image/'): raise ValueError(f"File at {file_path} doesn't appear to be an image") + await self.cacheAvatar(IMPORT_NAME, avatar_data) return avatar_data async def avatarSetPostTreatment(self, client, entity, avatar_data): """Update our own avatar""" await self.update(client, IMPORT_NAME, "avatar", avatar_data, entity) - def avatarBuildMetadata(self, path, media_type=None, cache_uid=None): + def avatarBuildMetadata( + self, + path: Path, + media_type: Optional[str] = None, + cache_uid: Optional[str] = None + ) -> Optional[Dict[str, Union[str, Path, None]]]: """Helper method to generate avatar metadata @param path(str, Path, None): path to avatar file diff -r 853cbaf56e9e -r 213e83a4ed10 sat/plugins/plugin_xep_0054.py --- a/sat/plugins/plugin_xep_0054.py Wed Jun 29 11:36:31 2022 +0200 +++ b/sat/plugins/plugin_xep_0054.py Wed Jun 29 11:47:48 2022 +0200 @@ -53,6 +53,7 @@ C.PI_NAME: "XEP 0054 Plugin", C.PI_IMPORT_NAME: IMPORT_NAME, C.PI_TYPE: "XEP", + C.PI_MODES: C.PLUG_MODE_BOTH, C.PI_PROTOCOLS: ["XEP-0054", "XEP-0153"], C.PI_DEPENDENCIES: ["IDENTITY"], C.PI_RECOMMENDATIONS: [], @@ -61,9 +62,6 @@ C.PI_DESCRIPTION: _("""Implementation of vcard-temp"""), } -AVATAR_PATH = "avatars" -AVATAR_DIM = (128, 128) - IQ_GET = '/iq[@type="get"]' NS_VCARD = "vcard-temp" VCARD_REQUEST = IQ_GET + '/vCard[@xmlns="' + NS_VCARD + '"]' # TODO: manage requests @@ -318,54 +316,6 @@ return self._i.avatarBuildMetadata( avatar_cache['path'], avatar_cache['mime_type'], avatar_hash) - def _buildSetAvatar(self, client, vcard_elt, avatar_data): - # XXX: this method is executed in a separate thread - if avatar_data["media_type"] == "image/svg+xml": - # for vector image, we save directly - img_buf = open(avatar_data["path"], "rb") - else: - # for bitmap image, we check size and resize if necessary - try: - img = Image.open(avatar_data["path"]) - except IOError as e: - raise exceptions.DataError(f"Can't open image: {e}") - - if img.size != AVATAR_DIM: - img.thumbnail(AVATAR_DIM) - if img.size[0] != img.size[1]: # we need to crop first - left, upper = (0, 0) - right, lower = img.size - offset = abs(right - lower) / 2 - if right == min(img.size): - upper += offset - lower -= offset - else: - left += offset - right -= offset - img = img.crop((left, upper, right, lower)) - img_buf = io.BytesIO() - # PNG is well supported among clients, so we convert to this format - img.save(img_buf, "PNG") - img_buf.seek(0) - avatar_data["media_type"] = "image/png" - - media_type = avatar_data["media_type"] - photo_elt = vcard_elt.addElement("PHOTO") - photo_elt.addElement("TYPE", content=media_type) - image_b64 = b64encode(img_buf.read()).decode() - img_buf.seek(0) - photo_elt.addElement("BINVAL", content=image_b64) - image_hash = sha1(img_buf.read()).hexdigest() - img_buf.seek(0) - with self.host.common_cache.cacheData( - PLUGIN_INFO["import_name"], image_hash, media_type - ) as f: - f.write(img_buf.read()) - avatar_data['path'] = Path(f.name) - avatar_data['filename'] = avatar_data['path'].name - avatar_data['cache_uid'] = image_hash - return image_hash - async def setAvatar(self, client, avatar_data, entity): """Set avatar of the profile @@ -377,10 +327,10 @@ iq_elt = client.IQ() iq_elt.addChild(vcard_elt) - await threads.deferToThread( - self._buildSetAvatar, client, vcard_elt, avatar_data - ) - # image is now at the right size/format + # metadata with encoded image are now filled at the right size/format + photo_elt = vcard_elt.addElement("PHOTO") + photo_elt.addElement("TYPE", content=avatar_data["media_type"]) + photo_elt.addElement("BINVAL", content=avatar_data["base64"]) await iq_elt.send()