Mercurial > libervia-backend
diff libervia/backend/plugins/plugin_misc_identity.py @ 4071:4b842c1fb686
refactoring: renamed `sat` package to `libervia.backend`
author | Goffi <goffi@goffi.org> |
---|---|
date | Fri, 02 Jun 2023 11:49:51 +0200 |
parents | sat/plugins/plugin_misc_identity.py@524856bd7b19 |
children | 5f2d496c633f |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/libervia/backend/plugins/plugin_misc_identity.py Fri Jun 02 11:49:51 2023 +0200 @@ -0,0 +1,809 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2009-2021 Jérôme Poisson (goffi@goffi.org) + +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. + +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. + +# 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 collections import namedtuple +import io +from pathlib import Path +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 libervia.backend.core import exceptions +from libervia.backend.core.constants import Const as C +from libervia.backend.core.i18n import _ +from libervia.backend.core.log import getLogger +from libervia.backend.core.xmpp import SatXMPPEntity +from libervia.backend.memory import persistent +from libervia.backend.tools import image +from libervia.backend.tools import utils +from libervia.backend.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__) + + +IMPORT_NAME = "IDENTITY" + + +PLUGIN_INFO = { + C.PI_NAME: "Identity Plugin", + C.PI_IMPORT_NAME: IMPORT_NAME, + C.PI_TYPE: C.PLUG_TYPE_MISC, + C.PI_MODES: C.PLUG_MODE_BOTH, + C.PI_PROTOCOLS: [], + C.PI_DEPENDENCIES: [], + C.PI_RECOMMENDATIONS: ["XEP-0045"], + C.PI_MAIN: "Identity", + C.PI_HANDLER: "no", + C.PI_DESCRIPTION: _("""Identity manager"""), +} + +Callback = namedtuple("Callback", ("origin", "get", "set", "priority")) +AVATAR_DIM = (128, 128) + + +class Identity: + + def __init__(self, host): + log.info(_("Plugin Identity initialization")) + self.host = host + self._m = host.plugins.get("XEP-0045") + self.metadata = { + "avatar": { + "type": dict, + # convert avatar path to avatar metadata (and check validity) + "set_data_filter": self.avatar_set_data_filter, + # update profile avatar, so all frontends are aware + "set_post_treatment": self.avatar_set_post_treatment, + "update_is_new_data": self.avatar_update_is_new_data, + "update_data_filter": self.avatar_update_data_filter, + # we store the metadata in database, to restore it on next connection + # (it is stored only for roster entities) + "store": True, + }, + "nicknames": { + "type": list, + # accumulate all nicknames from all callbacks in a list instead + # of returning only the data from the first successful callback + "get_all": True, + # append nicknames from roster, resource, etc. + "get_post_treatment": self.nicknames_get_post_treatment, + "update_is_new_data": self.nicknames_update_is_new_data, + "store": True, + }, + "description": { + "type": str, + "get_all": True, + "get_post_treatment": self.description_get_post_treatment, + "store": True, + } + } + host.trigger.add("roster_update", self._roster_update_trigger) + host.memory.set_signal_on_update("avatar") + host.memory.set_signal_on_update("nicknames") + host.bridge.add_method( + "identity_get", + ".plugin", + in_sign="sasbs", + out_sign="s", + method=self._get_identity, + async_=True, + ) + host.bridge.add_method( + "identities_get", + ".plugin", + in_sign="asass", + out_sign="s", + method=self._get_identities, + async_=True, + ) + host.bridge.add_method( + "identities_base_get", + ".plugin", + in_sign="s", + out_sign="s", + method=self._get_base_identities, + async_=True, + ) + host.bridge.add_method( + "identity_set", + ".plugin", + in_sign="ss", + out_sign="", + method=self._set_identity, + async_=True, + ) + host.bridge.add_method( + "avatar_get", + ".plugin", + in_sign="sbs", + out_sign="s", + method=self._getAvatar, + async_=True, + ) + host.bridge.add_method( + "avatar_set", + ".plugin", + in_sign="sss", + out_sign="", + method=self._set_avatar, + async_=True, + ) + + async def profile_connecting(self, client): + client._identity_update_lock = [] + # we restore known identities from database + client._identity_storage = persistent.LazyPersistentBinaryDict( + "identity", client.profile) + + stored_data = await client._identity_storage.all() + + to_delete = [] + + for key, value in stored_data.items(): + entity_s, name = key.split('\n') + if name not in self.metadata.keys(): + log.debug(f"removing {key} from storage: not an allowed metadata name") + to_delete.append(key) + continue + entity = jid.JID(entity_s) + + if name == 'avatar': + if value is not None: + try: + cache_uid = value['cache_uid'] + if not cache_uid: + raise ValueError + filename = value['filename'] + if not filename: + raise ValueError + except (ValueError, KeyError): + log.warning( + f"invalid data for {entity} avatar, it will be deleted: " + f"{value}") + to_delete.append(key) + continue + cache = self.host.common_cache.get_metadata(cache_uid) + if cache is None: + log.debug( + f"purging avatar for {entity}: it is not in cache anymore") + to_delete.append(key) + continue + + self.host.memory.update_entity_data( + client, entity, name, value, silent=True + ) + + for key in to_delete: + await client._identity_storage.adel(key) + + def _roster_update_trigger(self, client, roster_item): + old_item = client.roster.get_item(roster_item.jid) + if old_item is None or old_item.name != roster_item.name: + log.debug( + f"roster nickname has been updated to {roster_item.name!r} for " + f"{roster_item.jid}" + ) + defer.ensureDeferred( + self.update( + client, + IMPORT_NAME, + "nicknames", + [roster_item.name], + roster_item.jid + ) + ) + return True + + def register( + self, + origin: str, + metadata_name: str, + cb_get: Union[Coroutine, defer.Deferred], + cb_set: Union[Coroutine, defer.Deferred], + priority: int=0): + """Register callbacks to handle identity metadata + + @param origin: namespace of the plugin managing this metadata + @param metadata_name: name of metadata can be: + - avatar + - nicknames + @param cb_get: method to retrieve a metadata + the method will get client and metadata names to retrieve as arguments. + @param cb_set: method to set a metadata + the method will get client, metadata name to set, and value as argument. + @param priority: priority of this method for the given metadata. + methods with bigger priorities will be called first + """ + if not metadata_name in self.metadata.keys(): + raise ValueError(f"Invalid metadata_name: {metadata_name!r}") + callback = Callback(origin=origin, get=cb_get, set=cb_set, priority=priority) + cb_list = self.metadata[metadata_name].setdefault('callbacks', []) + cb_list.append(callback) + cb_list.sort(key=lambda c: c.priority, reverse=True) + + def get_identity_jid(self, client, peer_jid): + """Return jid to use to set identity metadata + + if it's a jid of a room occupant, full jid will be used + otherwise bare jid will be used + if None, bare jid of profile will be used + @return (jid.JID): jid to use for avatar + """ + if peer_jid is None: + return client.jid.userhostJID() + if self._m is None: + return peer_jid.userhostJID() + else: + return self._m.get_bare_or_full(client, peer_jid) + + def check_type(self, metadata_name, value): + """Check that type used for a metadata is the one declared in self.metadata""" + value_type = self.metadata[metadata_name]["type"] + if not isinstance(value, value_type): + raise ValueError( + f"{value} has wrong type: it is {type(value)} while {value_type} was " + f"expected") + + def get_field_type(self, metadata_name: str) -> str: + """Return the type the requested field + + @param metadata_name: name of the field to check + @raise KeyError: the request field doesn't exist + """ + return self.metadata[metadata_name]["type"] + + async def get( + self, + client: SatXMPPEntity, + metadata_name: str, + entity: Optional[jid.JID], + use_cache: bool=True, + prefilled_values: Optional[Dict[str, Any]]=None + ): + """Retrieve identity metadata of an entity + + if metadata is already in cache, it is returned. Otherwise, registered callbacks + will be tried in priority order (bigger to lower) + @param metadata_name: name of the metadata + must be one of self.metadata key + the name will also be used as entity data name in host.memory + @param entity: entity for which avatar is requested + None to use profile's jid + @param use_cache: if False, cache won't be checked + @param prefilled_values: map of origin => value to use when `get_all` is set + """ + entity = self.get_identity_jid(client, entity) + try: + metadata = self.metadata[metadata_name] + except KeyError: + raise ValueError(f"Invalid metadata name: {metadata_name!r}") + get_all = metadata.get('get_all', False) + if use_cache: + try: + data = self.host.memory.get_entity_datum( + client, entity, metadata_name) + except (KeyError, exceptions.UnknownEntityError): + pass + else: + return data + + try: + callbacks = metadata['callbacks'] + except KeyError: + log.warning(_("No callback registered for {metadata_name}") + .format(metadata_name=metadata_name)) + return [] if get_all else None + + if get_all: + all_data = [] + elif prefilled_values is not None: + raise exceptions.InternalError( + "prefilled_values can only be used when `get_all` is set") + + for callback in callbacks: + try: + if prefilled_values is not None and callback.origin in prefilled_values: + data = prefilled_values[callback.origin] + log.debug( + f"using prefilled values {data!r} for {metadata_name} with " + f"{callback.origin}") + else: + data = await defer.ensureDeferred(callback.get(client, entity)) + except exceptions.CancelError: + continue + except Exception as e: + log.warning( + _("Error while trying to get {metadata_name} with {callback}: {e}") + .format(callback=callback.get, metadata_name=metadata_name, e=e)) + else: + if data: + self.check_type(metadata_name, data) + if get_all: + if isinstance(data, list): + all_data.extend(data) + else: + all_data.append(data) + else: + break + else: + data = None + + if get_all: + data = all_data + + post_treatment = metadata.get("get_post_treatment") + if post_treatment is not None: + data = await utils.as_deferred(post_treatment, client, entity, data) + + self.host.memory.update_entity_data( + client, entity, metadata_name, data) + + if metadata.get('store', False): + key = f"{entity}\n{metadata_name}" + await client._identity_storage.aset(key, data) + + return data + + async def set(self, client, metadata_name, data, entity=None): + """Set identity metadata for an entity + + Registered callbacks will be tried in priority order (bigger to lower) + @param metadata_name(str): name of the metadata + must be one of self.metadata key + the name will also be used to set entity data in host.memory + @param data(object): value to set + @param entity(jid.JID, None): entity for which avatar is requested + None to use profile's jid + """ + entity = self.get_identity_jid(client, entity) + metadata = self.metadata[metadata_name] + data_filter = metadata.get("set_data_filter") + if data_filter is not None: + data = await utils.as_deferred(data_filter, client, entity, data) + self.check_type(metadata_name, data) + + try: + callbacks = metadata['callbacks'] + except KeyError: + log.warning(_("No callback registered for {metadata_name}") + .format(metadata_name=metadata_name)) + return exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}") + + for callback in callbacks: + try: + await defer.ensureDeferred(callback.set(client, data, entity)) + except exceptions.CancelError: + continue + except Exception as e: + log.warning( + _("Error while trying to set {metadata_name} with {callback}: {e}") + .format(callback=callback.set, metadata_name=metadata_name, e=e)) + else: + break + else: + raise exceptions.FeatureNotFound(f"Can't set {metadata_name} for {entity}") + + post_treatment = metadata.get("set_post_treatment") + if post_treatment is not None: + await utils.as_deferred(post_treatment, client, entity, data) + + async def update( + self, + client: SatXMPPEntity, + origin: str, + metadata_name: str, + data: Any, + entity: Optional[jid.JID] + ): + """Update a metadata in cache + + This method may be called by plugins when an identity metadata is available. + @param origin: namespace of the plugin which is source of the metadata + """ + entity = self.get_identity_jid(client, entity) + if (entity, metadata_name) in client._identity_update_lock: + log.debug(f"update is locked for {entity}'s {metadata_name}") + return + metadata = self.metadata[metadata_name] + + try: + cached_data = self.host.memory.get_entity_datum( + client, entity, metadata_name) + except (KeyError, exceptions.UnknownEntityError): + # metadata is not cached, we do the update + pass + else: + # metadata is cached, we check if the new value differs from the cached one + try: + update_is_new_data = metadata["update_is_new_data"] + except KeyError: + update_is_new_data = self.default_update_is_new_data + + if data is None: + if cached_data is None: + log.debug( + f"{metadata_name} for {entity} is already disabled, nothing to " + f"do") + return + elif cached_data is None: + pass + elif not update_is_new_data(client, entity, cached_data, data): + log.debug( + f"{metadata_name} for {entity} is already in cache, nothing to " + f"do") + return + + # we can't use the cache, so we do the update + + log.debug(f"updating {metadata_name} for {entity}") + + if metadata.get('get_all', False): + # get_all is set, meaning that we have to check all plugins + # so we first delete current cache + try: + self.host.memory.del_entity_datum(client, entity, metadata_name) + except (KeyError, exceptions.UnknownEntityError): + pass + # then fill it again by calling get, which will retrieve all values + # we lock update to avoid infinite recursions (update can be called during + # get callbacks) + client._identity_update_lock.append((entity, metadata_name)) + await self.get(client, metadata_name, entity, prefilled_values={origin: data}) + client._identity_update_lock.remove((entity, metadata_name)) + return + + if data is not None: + data_filter = metadata['update_data_filter'] + if data_filter is not None: + data = await utils.as_deferred(data_filter, client, entity, data) + self.check_type(metadata_name, data) + + self.host.memory.update_entity_data(client, entity, metadata_name, data) + + if metadata.get('store', False): + key = f"{entity}\n{metadata_name}" + await client._identity_storage.aset(key, data) + + def default_update_is_new_data(self, client, entity, cached_data, new_data): + return new_data != cached_data + + def _getAvatar(self, entity, use_cache, profile): + client = self.host.get_client(profile) + entity = jid.JID(entity) if entity else None + d = defer.ensureDeferred(self.get(client, "avatar", entity, use_cache)) + d.addCallback(lambda data: data_format.serialise(data)) + return d + + def _set_avatar(self, file_path, entity, profile_key=C.PROF_KEY_NONE): + client = self.host.get_client(profile_key) + entity = jid.JID(entity) if entity else None + return defer.ensureDeferred( + self.set(client, "avatar", file_path, entity)) + + def _blocking_cache_avatar( + 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.cache_data( + 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 cache_avatar(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 [avatar_set_data_filter] + 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._blocking_cache_avatar, source, avatar_data) + + async def avatar_set_data_filter(self, client, entity, file_path): + """Convert avatar file path to dict data""" + file_path = Path(file_path) + if not file_path.is_file(): + raise ValueError(f"There is no file at {file_path} to use as avatar") + avatar_data = { + 'path': file_path, + 'filename': file_path.name, + 'media_type': image.guess_type(file_path), + } + media_type = avatar_data['media_type'] + if media_type is None: + 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.cache_avatar(IMPORT_NAME, avatar_data) + return avatar_data + + async def avatar_set_post_treatment(self, client, entity, avatar_data): + """Update our own avatar""" + await self.update(client, IMPORT_NAME, "avatar", avatar_data, entity) + + def avatar_build_metadata( + 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 + avatar file must be in cache + None if avatar is explicitely not set + @param media_type(str, None): type of the avatar file (MIME type) + @param cache_uid(str, None): UID of avatar in cache + @return (dict, None): avatar metadata + None if avatar is not set + """ + if path is None: + return None + else: + if cache_uid is None: + raise ValueError("cache_uid must be set if path is set") + path = Path(path) + if media_type is None: + media_type = image.guess_type(path) + + return { + "path": path, + "filename": path.name, + "media_type": media_type, + "cache_uid": cache_uid, + } + + def avatar_update_is_new_data(self, client, entity, cached_data, new_data): + return new_data['path'] != cached_data['path'] + + async def avatar_update_data_filter(self, client, entity, data): + if not isinstance(data, dict): + raise ValueError(f"Invalid data type ({type(data)}), a dict is expected") + mandatory_keys = {'path', 'filename', 'cache_uid'} + if not data.keys() >= mandatory_keys: + raise ValueError(f"missing avatar data keys: {mandatory_keys - data.keys()}") + return data + + async def nicknames_get_post_treatment(self, client, entity, plugin_nicknames): + """Prepend nicknames from core locations + set default nickname + + nicknames are checked from many locations, there is always at least + one nickname. First nickname of the list can be used in priority. + Nicknames are appended in this order: + - roster, plugins set nicknames + - if no nickname is found, user part of jid is then used, or bare jid + if there is no user part. + For MUC, room nick is always put first + """ + nicknames = [] + + # for MUC we add resource + if entity.resource: + # get_identity_jid let the resource only if the entity is a MUC room + # occupant jid + nicknames.append(entity.resource) + + # we first check roster (if we are not in a component) + if not client.is_component: + roster_item = client.roster.get_item(entity.userhostJID()) + if roster_item is not None and roster_item.name: + # user set name has priority over entity set name + nicknames.append(roster_item.name) + + nicknames.extend(plugin_nicknames) + + if not nicknames: + if entity.user: + nicknames.append(entity.user.capitalize()) + else: + nicknames.append(entity.userhost()) + + # we remove duplicates while preserving order with dict + return list(dict.fromkeys(nicknames)) + + def nicknames_update_is_new_data(self, client, entity, cached_data, new_nicknames): + return not set(new_nicknames).issubset(cached_data) + + async def description_get_post_treatment( + self, + client: SatXMPPEntity, + entity: jid.JID, + plugin_description: List[str] + ) -> str: + """Join all descriptions in a unique string""" + return '\n'.join(plugin_description) + + def _get_identity(self, entity_s, metadata_filter, use_cache, profile): + entity = jid.JID(entity_s) + client = self.host.get_client(profile) + d = defer.ensureDeferred( + self.get_identity(client, entity, metadata_filter, use_cache)) + d.addCallback(data_format.serialise) + return d + + async def get_identity( + self, + client: SatXMPPEntity, + entity: Optional[jid.JID] = None, + metadata_filter: Optional[List[str]] = None, + use_cache: bool = True + ) -> Dict[str, Any]: + """Retrieve identity of an entity + + @param entity: entity to check + @param metadata_filter: if not None or empty, only return + metadata in this filter + @param use_cache: if False, cache won't be checked + should be True most of time, to avoid useless network requests + @return: identity data + """ + id_data = {} + + if not metadata_filter: + metadata_names = self.metadata.keys() + else: + metadata_names = metadata_filter + + for metadata_name in metadata_names: + id_data[metadata_name] = await self.get( + client, metadata_name, entity, use_cache) + + return id_data + + def _get_identities(self, entities_s, metadata_filter, profile): + entities = [jid.JID(e) for e in entities_s] + client = self.host.get_client(profile) + d = defer.ensureDeferred(self.get_identities(client, entities, metadata_filter)) + d.addCallback(lambda d: data_format.serialise({str(j):i for j, i in d.items()})) + return d + + async def get_identities( + self, + client: SatXMPPEntity, + entities: List[jid.JID], + metadata_filter: Optional[List[str]] = None, + ) -> dict: + """Retrieve several identities at once + + @param entities: entities from which identities must be retrieved + @param metadata_filter: same as for [get_identity] + @return: identities metadata where key is jid + if an error happens while retrieve a jid entity, it won't be present in the + result (and a warning will be logged) + """ + identities = {} + get_identity_list = [] + for entity_jid in entities: + get_identity_list.append( + defer.ensureDeferred( + self.get_identity( + client, + entity=entity_jid, + metadata_filter=metadata_filter, + ) + ) + ) + identities_result = await defer.DeferredList(get_identity_list) + for idx, (success, identity) in enumerate(identities_result): + entity_jid = entities[idx] + if not success: + log.warning(f"Can't get identity for {entity_jid}") + else: + identities[entity_jid] = identity + return identities + + def _get_base_identities(self, profile_key): + client = self.host.get_client(profile_key) + d = defer.ensureDeferred(self.get_base_identities(client)) + d.addCallback(lambda d: data_format.serialise({str(j):i for j, i in d.items()})) + return d + + async def get_base_identities( + self, + client: SatXMPPEntity, + ) -> dict: + """Retrieve identities for entities in roster + own identity + invitations + + @param with_guests: if True, get affiliations of people invited by email + + """ + if client.is_component: + entities = [client.jid.userhostJID()] + else: + entities = client.roster.get_jids() + [client.jid.userhostJID()] + + return await self.get_identities( + client, + entities, + ['avatar', 'nicknames'] + ) + + def _set_identity(self, id_data_s, profile): + client = self.host.get_client(profile) + id_data = data_format.deserialise(id_data_s) + return defer.ensureDeferred(self.set_identity(client, id_data)) + + async def set_identity(self, client, id_data): + """Update profile's identity + + @param id_data(dict): data to update, key can be one of self.metadata keys + """ + if not id_data.keys() <= self.metadata.keys(): + raise ValueError( + f"Invalid metadata names: {id_data.keys() - self.metadata.keys()}") + for metadata_name, data in id_data.items(): + try: + await self.set(client, metadata_name, data) + except Exception as e: + log.warning( + _("Can't set metadata {metadata_name!r}: {reason}") + .format(metadata_name=metadata_name, reason=e))