comparison sat/plugins/plugin_misc_identity.py @ 3816:213e83a4ed10

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
author Goffi <goffi@goffi.org>
date Wed, 29 Jun 2022 11:47:48 +0200
parents 888109774673
children 998c5318230f
comparison
equal deleted inserted replaced
3815:853cbaf56e9e 3816:213e83a4ed10
13 # GNU Affero General Public License for more details. 13 # GNU Affero General Public License for more details.
14 14
15 # You should have received a copy of the GNU Affero General Public License 15 # You should have received a copy of the GNU Affero General Public License
16 # along with this program. If not, see <http://www.gnu.org/licenses/>. 16 # along with this program. If not, see <http://www.gnu.org/licenses/>.
17 17
18 from typing import Dict, List, Union, Coroutine, Any, Optional
19 from collections import namedtuple 18 from collections import namedtuple
19 import io
20 from pathlib import Path 20 from pathlib import Path
21 from twisted.internet import defer 21 from base64 import b64encode
22 import hashlib
23 from typing import Any, Coroutine, Dict, List, Optional, Union
24
25 from twisted.internet import defer, threads
22 from twisted.words.protocols.jabber import jid 26 from twisted.words.protocols.jabber import jid
27
28 from sat.core import exceptions
29 from sat.core.constants import Const as C
30 from sat.core.i18n import _
31 from sat.core.log import getLogger
23 from sat.core.xmpp import SatXMPPEntity 32 from sat.core.xmpp import SatXMPPEntity
24 from sat.core.i18n import _
25 from sat.core.constants import Const as C
26 from sat.core import exceptions
27 from sat.core.log import getLogger
28 from sat.memory import persistent 33 from sat.memory import persistent
29 from sat.tools import image 34 from sat.tools import image
30 from sat.tools import utils 35 from sat.tools import utils
31 from sat.tools.common import data_format 36 from sat.tools.common import data_format
37
38 try:
39 from PIL import Image
40 except:
41 raise exceptions.MissingModule(
42 "Missing module pillow, please download/install it from https://python-pillow.github.io"
43 )
44
32 45
33 46
34 log = getLogger(__name__) 47 log = getLogger(__name__)
35 48
36 49
48 C.PI_HANDLER: "no", 61 C.PI_HANDLER: "no",
49 C.PI_DESCRIPTION: _("""Identity manager"""), 62 C.PI_DESCRIPTION: _("""Identity manager"""),
50 } 63 }
51 64
52 Callback = namedtuple("Callback", ("origin", "get", "set", "priority")) 65 Callback = namedtuple("Callback", ("origin", "get", "set", "priority"))
66 AVATAR_DIM = (128, 128)
53 67
54 68
55 class Identity: 69 class Identity:
56 70
57 def __init__(self, host): 71 def __init__(self, host):
472 client = self.host.getClient(profile_key) 486 client = self.host.getClient(profile_key)
473 entity = jid.JID(entity) if entity else None 487 entity = jid.JID(entity) if entity else None
474 return defer.ensureDeferred( 488 return defer.ensureDeferred(
475 self.set(client, "avatar", file_path, entity)) 489 self.set(client, "avatar", file_path, entity))
476 490
491 def _blockingCacheAvatar(
492 self,
493 source: str,
494 avatar_data: dict[str, Any]
495 ):
496 """This method is executed in a separated thread"""
497 if avatar_data["media_type"] == "image/svg+xml":
498 # for vector image, we save directly
499 img_buf = open(avatar_data["path"], "rb")
500 else:
501 # for bitmap image, we check size and resize if necessary
502 try:
503 img = Image.open(avatar_data["path"])
504 except IOError as e:
505 raise exceptions.DataError(f"Can't open image: {e}")
506
507 if img.size != AVATAR_DIM:
508 img.thumbnail(AVATAR_DIM)
509 if img.size[0] != img.size[1]: # we need to crop first
510 left, upper = (0, 0)
511 right, lower = img.size
512 offset = abs(right - lower) / 2
513 if right == min(img.size):
514 upper += offset
515 lower -= offset
516 else:
517 left += offset
518 right -= offset
519 img = img.crop((left, upper, right, lower))
520 img_buf = io.BytesIO()
521 # PNG is well supported among clients, so we convert to this format
522 img.save(img_buf, "PNG")
523 img_buf.seek(0)
524 avatar_data["media_type"] = "image/png"
525
526 media_type = avatar_data["media_type"]
527 avatar_data["base64"] = image_b64 = b64encode(img_buf.read()).decode()
528 img_buf.seek(0)
529 image_hash = hashlib.sha1(img_buf.read()).hexdigest()
530 img_buf.seek(0)
531 with self.host.common_cache.cacheData(
532 source, image_hash, media_type
533 ) as f:
534 f.write(img_buf.read())
535 avatar_data['path'] = Path(f.name)
536 avatar_data['filename'] = avatar_data['path'].name
537 avatar_data['cache_uid'] = image_hash
538
539 async def cacheAvatar(self, source: str, avatar_data: Dict[str, Any]) -> None:
540 """Resize if necessary and cache avatar
541
542 @param source: source importing the avatar (usually it is plugin's import name),
543 will be used in cache metadata
544 @param avatar_data: avatar metadata as build by [avatarSetDataFilter]
545 will be updated with following keys:
546 path: updated path using cached file
547 filename: updated filename using cached file
548 base64: resized and base64 encoded avatar
549 cache_uid: SHA1 hash used as cache unique ID
550 """
551 await threads.deferToThread(self._blockingCacheAvatar, source, avatar_data)
552
477 async def avatarSetDataFilter(self, client, entity, file_path): 553 async def avatarSetDataFilter(self, client, entity, file_path):
478 """Convert avatar file path to dict data""" 554 """Convert avatar file path to dict data"""
479 file_path = Path(file_path) 555 file_path = Path(file_path)
480 if not file_path.is_file(): 556 if not file_path.is_file():
481 raise ValueError(f"There is no file at {file_path} to use as avatar") 557 raise ValueError(f"There is no file at {file_path} to use as avatar")
487 media_type = avatar_data['media_type'] 563 media_type = avatar_data['media_type']
488 if media_type is None: 564 if media_type is None:
489 raise ValueError(f"Can't identify type of image at {file_path}") 565 raise ValueError(f"Can't identify type of image at {file_path}")
490 if not media_type.startswith('image/'): 566 if not media_type.startswith('image/'):
491 raise ValueError(f"File at {file_path} doesn't appear to be an image") 567 raise ValueError(f"File at {file_path} doesn't appear to be an image")
568 await self.cacheAvatar(IMPORT_NAME, avatar_data)
492 return avatar_data 569 return avatar_data
493 570
494 async def avatarSetPostTreatment(self, client, entity, avatar_data): 571 async def avatarSetPostTreatment(self, client, entity, avatar_data):
495 """Update our own avatar""" 572 """Update our own avatar"""
496 await self.update(client, IMPORT_NAME, "avatar", avatar_data, entity) 573 await self.update(client, IMPORT_NAME, "avatar", avatar_data, entity)
497 574
498 def avatarBuildMetadata(self, path, media_type=None, cache_uid=None): 575 def avatarBuildMetadata(
576 self,
577 path: Path,
578 media_type: Optional[str] = None,
579 cache_uid: Optional[str] = None
580 ) -> Optional[Dict[str, Union[str, Path, None]]]:
499 """Helper method to generate avatar metadata 581 """Helper method to generate avatar metadata
500 582
501 @param path(str, Path, None): path to avatar file 583 @param path(str, Path, None): path to avatar file
502 avatar file must be in cache 584 avatar file must be in cache
503 None if avatar is explicitely not set 585 None if avatar is explicitely not set