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
This commit is contained in:
Goffi 2022-06-29 11:47:48 +02:00
commit 35be6b741f
2 changed files with 93 additions and 61 deletions

View file

@ -15,21 +15,34 @@
# 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 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.constants import Const as C
from sat.core import exceptions
from sat.core.constants import Const as C
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 @@ PLUGIN_INFO = {
}
Callback = namedtuple("Callback", ("origin", "get", "set", "priority"))
AVATAR_DIM = (128, 128)
class Identity:
@ -474,6 +488,68 @@ class Identity:
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 @@ class Identity:
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

View file

@ -53,6 +53,7 @@ PLUGIN_INFO = {
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 @@ PLUGIN_INFO = {
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 @@ class XEP_0054(object):
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 @@ class XEP_0054(object):
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()