2337 lines
91 KiB
Python
2337 lines
91 KiB
Python
#!/usr/bin/env python3
|
||
|
||
# Libervia: an XMPP client
|
||
# Copyright (C) 2009-2026 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
|
||
from collections.abc import Iterable
|
||
import copy
|
||
from functools import partial
|
||
import mimetypes
|
||
import os.path
|
||
from pathlib import Path
|
||
import time
|
||
from typing import Any, Callable, Dict, NamedTuple, Optional, Tuple
|
||
from uuid import uuid4
|
||
|
||
import shortuuid
|
||
from twisted.internet import defer, error, reactor
|
||
from twisted.internet.base import DelayedCall
|
||
from twisted.words.protocols.jabber import jid
|
||
|
||
from libervia.backend import G
|
||
from libervia.backend.core import exceptions
|
||
from libervia.backend.core.constants import Const as C
|
||
from libervia.backend.core.core_types import SatXMPPClient, SatXMPPEntity
|
||
from libervia.backend.core.i18n import _
|
||
from libervia.backend.core.log import getLogger
|
||
from libervia.backend.memory.crypto import BlockCipher
|
||
from libervia.backend.memory.crypto import PasswordHasher
|
||
from libervia.backend.memory.disco import Discovery
|
||
from libervia.backend.memory.params import Params
|
||
from libervia.backend.memory.persistent import PersistentDict
|
||
from libervia.backend.memory.sqla import (
|
||
NotificationPriority,
|
||
NotificationStatus,
|
||
NotificationType,
|
||
Storage,
|
||
)
|
||
from libervia.backend.models.core import (
|
||
HistoryLastOptions,
|
||
HistoryLastResult,
|
||
HistoryPeerInfo,
|
||
)
|
||
from libervia.backend.tools import config as tools_config
|
||
from libervia.backend.tools.common import data_format
|
||
from libervia.backend.tools.common import regex
|
||
|
||
log = getLogger(__name__)
|
||
|
||
|
||
PresenceTuple = namedtuple("PresenceTuple", ("show", "priority", "statuses"))
|
||
MSG_NO_SESSION = "Session id doesn't exist or is finished"
|
||
|
||
|
||
SessionData = dict[str, Any]
|
||
|
||
|
||
class SessionInfo(NamedTuple):
|
||
timer: DelayedCall
|
||
data: SessionData
|
||
profile: str | None = None
|
||
|
||
|
||
class Sessions:
|
||
"""Temporary data handling.
|
||
|
||
Sessions store data associated with a key for temporary use, with optional profile
|
||
validation.
|
||
"""
|
||
|
||
DEFAULT_TIMEOUT = 600
|
||
|
||
def __init__(self, timeout: int | None = None, resettable_timeout: bool = True):
|
||
"""
|
||
@param timeout (int): nb of seconds before session destruction
|
||
@param resettable_timeout (bool): if True, the timeout is reset on each access
|
||
"""
|
||
self._sessions: dict[str, SessionInfo] = dict()
|
||
self.timeout = timeout or Sessions.DEFAULT_TIMEOUT
|
||
self.resettable_timeout = resettable_timeout
|
||
|
||
def new_session(
|
||
self,
|
||
session_data: SessionData | None = None,
|
||
session_id: str | None = None,
|
||
profile: str | None = None,
|
||
) -> tuple[str, SessionData]:
|
||
"""Create a new session
|
||
|
||
@param session_data: mutable data to use, default to a dict
|
||
@param session_id (str): force the session_id to the given string
|
||
@param profile: if set, the session is owned by the profile,
|
||
and profile_get must be used instead of __getitem__
|
||
@return: session_id, session_data
|
||
"""
|
||
if session_id is None:
|
||
session_id = str(uuid4())
|
||
elif session_id in self._sessions:
|
||
raise exceptions.ConflictError(
|
||
"Session id {} is already used".format(session_id)
|
||
)
|
||
timer = reactor.callLater(self.timeout, self._purge_session, session_id)
|
||
if session_data is None:
|
||
session_data = SessionData()
|
||
self._sessions[session_id] = SessionInfo(timer, session_data, profile)
|
||
return session_id, session_data
|
||
|
||
def _purge_session(self, session_id: str):
|
||
timer, __, profile = self._sessions[session_id]
|
||
try:
|
||
timer.cancel()
|
||
except error.AlreadyCalled:
|
||
# if the session is time-outed, the timer has been called
|
||
pass
|
||
del self._sessions[session_id]
|
||
log.debug(
|
||
"Session {} purged{}".format(
|
||
session_id,
|
||
" (profile {})".format(profile) if profile is not None else "",
|
||
)
|
||
)
|
||
|
||
def __len__(self) -> int:
|
||
return len(self._sessions)
|
||
|
||
def __contains__(self, session_id: str) -> bool:
|
||
return session_id in self._sessions
|
||
|
||
def profile_get(self, session_id: str, profile: str) -> SessionData:
|
||
try:
|
||
timer, session_data, profile_set = self._sessions[session_id]
|
||
except ValueError:
|
||
raise exceptions.InternalError(
|
||
"You need to use __getitem__ when profile is not set"
|
||
)
|
||
except KeyError:
|
||
raise KeyError(MSG_NO_SESSION)
|
||
if profile_set != profile:
|
||
raise exceptions.InternalError("current profile differ from set profile !")
|
||
if self.resettable_timeout:
|
||
timer.reset(self.timeout)
|
||
return session_data
|
||
|
||
def __getitem__(self, session_id: str) -> SessionData:
|
||
try:
|
||
session_info = self._sessions[session_id]
|
||
except KeyError:
|
||
raise KeyError(MSG_NO_SESSION)
|
||
if session_info.profile is not None:
|
||
raise exceptions.InternalError(
|
||
"You need to use profile_get instead of __getitem__ when profile is set."
|
||
)
|
||
if self.resettable_timeout:
|
||
session_info.timer.reset(self.timeout)
|
||
return session_info.data
|
||
|
||
def __setitem__(self, key: str, value) -> None:
|
||
raise NotImplementedError("You need do use new_session to create a session")
|
||
|
||
def __delitem__(self, session_id: str) -> None:
|
||
"""delete the session data"""
|
||
self._purge_session(session_id)
|
||
|
||
def keys(self) -> list[str]:
|
||
return list(self._sessions.keys())
|
||
|
||
def iterkeys(self) -> Iterable[str]:
|
||
return iter(self._sessions.keys())
|
||
|
||
|
||
class ProfileSessions(Sessions):
|
||
"""ProfileSessions extends the Sessions class, but here the profile can be
|
||
used as the key to retrieve data or delete a session (instead of session id).
|
||
"""
|
||
|
||
def _profile_get_all_ids(self, profile):
|
||
"""Return a list of the sessions ids that are associated to the given profile.
|
||
|
||
@param profile: %(doc_profile)s
|
||
@return: a list containing the sessions ids
|
||
"""
|
||
ret = []
|
||
for session_id in self._sessions.keys():
|
||
try:
|
||
timer, session_data, profile_set = self._sessions[session_id]
|
||
except ValueError:
|
||
continue
|
||
if profile == profile_set:
|
||
ret.append(session_id)
|
||
return ret
|
||
|
||
def profile_get_unique(self, profile):
|
||
"""Return the data of the unique session that is associated to the given profile.
|
||
|
||
@param profile: %(doc_profile)s
|
||
@return:
|
||
- mutable data (default: dict) of the unique session
|
||
- None if no session is associated to the profile
|
||
- raise an error if more than one session are found
|
||
"""
|
||
ids = self._profile_get_all_ids(profile)
|
||
if len(ids) > 1:
|
||
raise exceptions.InternalError(
|
||
"profile_get_unique has been used but more than one session has been "
|
||
"found!"
|
||
)
|
||
return (
|
||
self.profile_get(ids[0], profile) if len(ids) == 1 else None
|
||
) # XXX: timeout might be reset
|
||
|
||
def profile_del_unique(self, profile):
|
||
"""Delete the unique session that is associated to the given profile.
|
||
|
||
@param profile: %(doc_profile)s
|
||
@return: None, but raise an error if more than one session are found
|
||
"""
|
||
ids = self._profile_get_all_ids(profile)
|
||
if len(ids) > 1:
|
||
raise exceptions.InternalError(
|
||
"profile_del_unique has been used but more than one session has been found!"
|
||
)
|
||
if len(ids) == 1:
|
||
del self._sessions[ids[0]]
|
||
|
||
|
||
class PasswordSessions(ProfileSessions):
|
||
|
||
# FIXME: temporary hack for the user personal key not to be lost. The session
|
||
# must actually be purged and later, when the personal key is needed, the
|
||
# profile password should be asked again in order to decrypt it.
|
||
def __init__(self, timeout=None):
|
||
ProfileSessions.__init__(self, timeout, resettable_timeout=False)
|
||
|
||
def _purge_session(self, session_id):
|
||
log.debug(
|
||
"FIXME: PasswordSessions should ask for the profile password after the session expired"
|
||
)
|
||
|
||
|
||
class Memory:
|
||
"""This class manage all the persistent information"""
|
||
|
||
def __init__(self, host):
|
||
log.info(_("Memory manager init"))
|
||
self.host = host
|
||
self._entities_cache = {} # XXX: keep presence/last resource/other data in cache
|
||
# /!\ an entity is not necessarily in roster
|
||
# main key is bare jid, value is a dict
|
||
# where main key is resource, or None for bare jid
|
||
self._key_signals = set() # key which need a signal to frontends when updated
|
||
self.subscriptions = {}
|
||
self.auth_sessions = PasswordSessions() # remember the authenticated profiles
|
||
self.disco = Discovery(host)
|
||
self.config: tools_config.Settings = tools_config.parse_main_conf(
|
||
log_filenames=True
|
||
)
|
||
self._cache_path = self.config.common.local_dir / C.CACHE_DIR
|
||
self.admins = self.config.common.admins_list
|
||
self.admin_jids = set()
|
||
self._file_path_lock = defer.DeferredLock()
|
||
|
||
def reparse_configuration(self) -> None:
|
||
"""Reparse configuration.
|
||
|
||
This is needed when dynamic settings has been added (after importing plugins).
|
||
"""
|
||
self.config = tools_config.parse_main_conf(ignore_cache=True)
|
||
|
||
async def initialise(self):
|
||
self.storage = Storage()
|
||
await self.storage.initialise()
|
||
PersistentDict.storage = self.storage
|
||
self.params = Params(self.host, self.storage)
|
||
log.info(_("Loading default params template"))
|
||
self.params.load_default_params()
|
||
await self.load()
|
||
self.memory_data = PersistentDict("memory")
|
||
await self.memory_data.load()
|
||
await self.disco.load()
|
||
for admin in self.admins:
|
||
try:
|
||
admin_jid_s = await self.param_get_a_async(
|
||
"JabberID", "Connection", profile_key=admin
|
||
)
|
||
except Exception as e:
|
||
log.warning(f"Can't retrieve jid of admin {admin!r}: {e}")
|
||
else:
|
||
if admin_jid_s is not None:
|
||
try:
|
||
admin_jid = jid.JID(admin_jid_s).userhostJID()
|
||
except RuntimeError:
|
||
log.warning(f"Invalid JID for admin {admin}: {admin_jid_s}")
|
||
else:
|
||
self.admin_jids.add(admin_jid)
|
||
|
||
## Configuration ##
|
||
|
||
def load_xml(self, filename):
|
||
"""Load parameters template from xml file
|
||
|
||
@param filename (str): input file
|
||
@return: bool: True in case of success
|
||
"""
|
||
if not filename:
|
||
return False
|
||
filename = os.path.expanduser(filename)
|
||
if os.path.exists(filename):
|
||
try:
|
||
self.params.load_xml(filename)
|
||
log.debug(_("Parameters loaded from file: %s") % filename)
|
||
return True
|
||
except Exception as e:
|
||
log.error(_("Can't load parameters from file: %s") % e)
|
||
return False
|
||
|
||
def save_xml(self, filename):
|
||
"""Save parameters template to xml file
|
||
|
||
@param filename (str): output file
|
||
@return: bool: True in case of success
|
||
"""
|
||
if not filename:
|
||
return False
|
||
# TODO: need to encrypt files (at least passwords !) and set permissions
|
||
filename = os.path.expanduser(filename)
|
||
try:
|
||
self.params.save_xml(filename)
|
||
log.debug(_("Parameters saved to file: %s") % filename)
|
||
return True
|
||
except Exception as e:
|
||
log.error(_("Can't save parameters to file: %s") % e)
|
||
return False
|
||
|
||
def load(self):
|
||
"""Load parameters and all memory things from db"""
|
||
# parameters data
|
||
return self.params.load_gen_params()
|
||
|
||
def load_individual_params(self, profile):
|
||
"""Load individual parameters for a profile
|
||
@param profile: %(doc_profile)s"""
|
||
return self.params.load_ind_params(profile)
|
||
|
||
## Profiles/Sessions management ##
|
||
|
||
def start_session(self, password, profile):
|
||
""" "Iniatialise session for a profile
|
||
|
||
@param password(unicode): profile session password
|
||
or empty string is no password is set
|
||
@param profile: %(doc_profile)s
|
||
@raise exceptions.ProfileUnknownError if profile doesn't exists
|
||
@raise exceptions.PasswordError: the password does not match
|
||
"""
|
||
profile = self.get_profile_name(profile)
|
||
|
||
def create_session(__):
|
||
"""Called once params are loaded."""
|
||
self._entities_cache[profile] = {}
|
||
log.info("[{}] Profile session started".format(profile))
|
||
return False
|
||
|
||
def backend_initialised(__):
|
||
def do_start_session(__=None):
|
||
if self.is_session_started(profile):
|
||
log.info("Session already started!")
|
||
return True
|
||
try:
|
||
# if there is a value at this point in self._entities_cache,
|
||
# it is the load_individual_params Deferred, the session is starting
|
||
session_d = self._entities_cache[profile]
|
||
except KeyError:
|
||
# else we do request the params
|
||
session_d = self._entities_cache[profile] = (
|
||
self.load_individual_params(profile)
|
||
)
|
||
session_d.addCallback(create_session)
|
||
finally:
|
||
return session_d
|
||
|
||
auth_d = defer.ensureDeferred(self.profile_authenticate(password, profile))
|
||
auth_d.addCallback(do_start_session)
|
||
return auth_d
|
||
|
||
if self.host.init_pre_script.called:
|
||
return defer.succeed(None).addCallback(backend_initialised)
|
||
else:
|
||
return self.host.init_pre_script.addCallback(backend_initialised)
|
||
|
||
def stop_session(self, profile):
|
||
"""Delete a profile session
|
||
|
||
@param profile: %(doc_profile)s
|
||
"""
|
||
if self.host.is_connected(profile):
|
||
log.debug("Disconnecting profile because of session stop")
|
||
self.host.disconnect(profile)
|
||
self.auth_sessions.profile_del_unique(profile)
|
||
try:
|
||
self._entities_cache[profile]
|
||
except KeyError:
|
||
log.warning("Profile was not in cache")
|
||
|
||
def _is_session_started(self, profile_key):
|
||
return self.is_session_started(self.get_profile_name(profile_key))
|
||
|
||
def is_session_started(self, profile):
|
||
try:
|
||
# XXX: if the value in self._entities_cache is a Deferred,
|
||
# the session is starting but not started yet
|
||
return not isinstance(self._entities_cache[profile], defer.Deferred)
|
||
except KeyError:
|
||
return False
|
||
|
||
async def profile_authenticate(self, password, profile):
|
||
"""Authenticate the profile.
|
||
|
||
@param password (unicode): the SàT profile password
|
||
@return: None in case of success (an exception is raised otherwise)
|
||
@raise exceptions.PasswordError: the password does not match
|
||
"""
|
||
if not password and self.auth_sessions.profile_get_unique(profile):
|
||
# XXX: this allows any frontend to connect with the empty password as soon as
|
||
# the profile has been authenticated at least once before. It is OK as long as
|
||
# submitting a form with empty passwords is restricted to local frontends.
|
||
return
|
||
|
||
sat_cipher = await self.param_get_a_async(
|
||
C.PROFILE_PASS_PATH[1], C.PROFILE_PASS_PATH[0], profile_key=profile
|
||
)
|
||
valid = PasswordHasher.verify(password, sat_cipher)
|
||
if not valid:
|
||
log.warning(
|
||
_("Authentication failure of profile {profile}").format(profile=profile)
|
||
)
|
||
raise exceptions.PasswordError("The provided profile password doesn't match.")
|
||
return await self.new_auth_session(password, profile)
|
||
|
||
async def new_auth_session(self, key, profile):
|
||
"""Start a new session for the authenticated profile.
|
||
|
||
If there is already an existing session, no new one is created
|
||
The personal key is loaded encrypted from a PersistentDict before being decrypted.
|
||
|
||
@param key: the key to decrypt the personal key
|
||
@param profile: %(doc_profile)s
|
||
"""
|
||
data = await PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load()
|
||
personal_key = BlockCipher.decrypt(key, data[C.MEMORY_CRYPTO_KEY])
|
||
# Create the session for this profile and store the personal key
|
||
session_data = self.auth_sessions.profile_get_unique(profile)
|
||
if not session_data:
|
||
self.auth_sessions.new_session(
|
||
{C.MEMORY_CRYPTO_KEY: personal_key}, profile=profile
|
||
)
|
||
log.debug("auth session created for profile %s" % profile)
|
||
|
||
def purge_profile_session(self, profile):
|
||
"""Delete cache of data of profile
|
||
@param profile: %(doc_profile)s"""
|
||
log.info(_("[%s] Profile session purge" % profile))
|
||
self.params.purge_profile(profile)
|
||
try:
|
||
del self._entities_cache[profile]
|
||
except KeyError:
|
||
log.error(
|
||
_("Trying to purge roster status cache for a profile not in memory: [%s]")
|
||
% profile
|
||
)
|
||
|
||
def get_profiles_list(self, clients=True, components=False):
|
||
"""retrieve profiles list
|
||
|
||
@param clients(bool): if True return clients profiles
|
||
@param components(bool): if True return components profiles
|
||
@return (list[unicode]): selected profiles
|
||
"""
|
||
if not clients and not components:
|
||
log.warning(_("requesting no profiles at all"))
|
||
return []
|
||
profiles = self.storage.get_profiles_list()
|
||
if clients and components:
|
||
return sorted(profiles)
|
||
is_component = self.storage.profile_is_component
|
||
if clients:
|
||
p_filter = lambda p: not is_component(p)
|
||
else:
|
||
p_filter = lambda p: is_component(p)
|
||
|
||
return sorted(p for p in profiles if p_filter(p))
|
||
|
||
def get_profile_name(self, profile_key, return_profile_keys=False):
|
||
"""Return name of profile from keyword
|
||
|
||
@param profile_key: can be the profile name or a keyword (like @DEFAULT@)
|
||
@param return_profile_keys: if True, return unmanaged profile keys (like "@ALL@"). This keys must be managed by the caller
|
||
@return: requested profile name
|
||
@raise exceptions.ProfileUnknownError if profile doesn't exists
|
||
"""
|
||
return self.params.get_profile_name(profile_key, return_profile_keys)
|
||
|
||
def profile_set_default(self, profile):
|
||
"""Set default profile
|
||
|
||
@param profile: %(doc_profile)s
|
||
"""
|
||
# we want to be sure that the profile exists
|
||
profile = self.get_profile_name(profile)
|
||
|
||
self.memory_data["Profile_default"] = profile
|
||
|
||
def create_profile(
|
||
self, name: str, password: str, component: str | None = None
|
||
) -> defer.Deferred[None]:
|
||
"""Create a new profile
|
||
|
||
@param name: profile name
|
||
@param password: profile password (can be empty to disable password)
|
||
@param component: entry point if this is a component
|
||
@return: Deferred that fires when the profile is created
|
||
@raise ValueError: if profile name is empty, starts with '@', or contains line
|
||
feed
|
||
@raise exceptions.ConflictError: if a session for this profile exists
|
||
@raise exceptions.NotFound: if component is not a known plugin import name
|
||
"""
|
||
if not name:
|
||
raise ValueError("Empty profile name")
|
||
if name[0] == "@":
|
||
raise ValueError("A profile name can't start with a '@'")
|
||
if "\n" in name:
|
||
raise ValueError("A profile name can't contain line feed ('\\n')")
|
||
|
||
if name in self._entities_cache:
|
||
raise exceptions.ConflictError("A session for this profile exists")
|
||
|
||
if component:
|
||
if not component in self.host.plugins:
|
||
raise exceptions.NotFound(
|
||
_(
|
||
"Can't find component {component} entry point".format(
|
||
component=component
|
||
)
|
||
)
|
||
)
|
||
# FIXME: PLUGIN_INFO is not currently accessible after import, but type should
|
||
# be tested here
|
||
# if self.host.plugins[component].PLUGIN_INFO[u"type"] != C.PLUG_TYPE_ENTRY_POINT:
|
||
# raise ValueError(_(u"Plugin {component} is not an entry point !".format(
|
||
# component = component)))
|
||
|
||
d = self.params.create_profile(name, component)
|
||
|
||
def init_personal_key(__):
|
||
# be sure to call this after checking that the profile doesn't exist yet
|
||
|
||
# generated once for all and saved in a PersistentDict
|
||
personal_key = BlockCipher.get_random_key(base64=True).decode("utf-8")
|
||
self.auth_sessions.new_session(
|
||
{C.MEMORY_CRYPTO_KEY: personal_key}, profile=name
|
||
) # will be encrypted by param_set
|
||
|
||
def start_fake_session(__):
|
||
# avoid ProfileNotConnected exception in param_set
|
||
self._entities_cache[name] = None
|
||
self.params.load_ind_params(name)
|
||
|
||
def stop_fake_session(__):
|
||
del self._entities_cache[name]
|
||
self.params.purge_profile(name)
|
||
|
||
d.addCallback(init_personal_key)
|
||
d.addCallback(start_fake_session)
|
||
d.addCallback(
|
||
lambda __: self.param_set(
|
||
C.PROFILE_PASS_PATH[1], password, C.PROFILE_PASS_PATH[0], profile_key=name
|
||
)
|
||
)
|
||
d.addCallback(stop_fake_session)
|
||
d.addCallback(lambda __: self.auth_sessions.profile_del_unique(name))
|
||
return d
|
||
|
||
def profile_delete_async(self, name, force=False):
|
||
"""Delete an existing profile
|
||
|
||
@param name: Name of the profile
|
||
@param force: force the deletion even if the profile is connected.
|
||
To be used for direct calls only (not through the bridge).
|
||
@return: a Deferred instance
|
||
"""
|
||
|
||
def clean_memory(__):
|
||
self.auth_sessions.profile_del_unique(name)
|
||
try:
|
||
del self._entities_cache[name]
|
||
except KeyError:
|
||
pass
|
||
|
||
d = self.params.profile_delete_async(name, force)
|
||
d.addCallback(clean_memory)
|
||
return d
|
||
|
||
def is_component(self, profile_name):
|
||
"""Tell if a profile is a component
|
||
|
||
@param profile_name(unicode): name of the profile
|
||
@return (bool): True if profile is a component
|
||
@raise exceptions.NotFound: profile doesn't exist
|
||
"""
|
||
return self.storage.profile_is_component(profile_name)
|
||
|
||
def get_entry_point(self, profile_name: str) -> str:
|
||
"""Get a component entry point for a given profile.
|
||
|
||
@param profile_name: name of the profile
|
||
@return: the component entry point (import name) for the profile
|
||
@raise exceptions.NotFound: if the profile doesn't exist or is not a component
|
||
"""
|
||
return self.storage.get_entry_point(profile_name)
|
||
|
||
## History ##
|
||
|
||
def add_to_history(self, client, data):
|
||
d = self.storage.add_to_history(data, client.profile)
|
||
# Update display state in memory
|
||
own_bare = client.jid.userhostJID()
|
||
from_bare = data["from"].userhostJID()
|
||
to_bare = data["to"].userhostJID()
|
||
is_outgoing = from_bare == own_bare
|
||
peer_jid = to_bare if is_outgoing else from_bare
|
||
state = client.display_state.setdefault(peer_jid, {"count": 0, "last_uid": None})
|
||
if is_outgoing:
|
||
# Outgoing message: user is looking at this chat, reset counter and update
|
||
# last displayed UID to this message.
|
||
state["last_uid"] = data["uid"]
|
||
if state["count"] > 0:
|
||
state["count"] = 0
|
||
self.host.bridge.messages_display_state_reset(
|
||
peer_jid.userhost(), client.profile
|
||
)
|
||
return d.addCallback(
|
||
lambda _: defer.ensureDeferred(
|
||
self.storage.set_private_value(
|
||
C.KEY_LAST_DISP_MSG,
|
||
peer_jid.userhost(),
|
||
data["uid"],
|
||
profile=client.profile,
|
||
)
|
||
)
|
||
)
|
||
else:
|
||
# Incoming message: increment counter
|
||
state["count"] += 1
|
||
return d
|
||
|
||
def _history_get_serialise(self, history_data):
|
||
return [
|
||
(
|
||
uid,
|
||
timestamp,
|
||
from_jid,
|
||
to_jid,
|
||
message,
|
||
subject,
|
||
mess_type,
|
||
data_format.serialise(extra),
|
||
)
|
||
for uid, timestamp, from_jid, to_jid, message, subject, mess_type, extra in history_data
|
||
]
|
||
|
||
def _history_get(
|
||
self,
|
||
from_jid_s,
|
||
to_jid_s,
|
||
limit=C.HISTORY_LIMIT_NONE,
|
||
between=True,
|
||
filters=None,
|
||
profile=C.PROF_KEY_NONE,
|
||
):
|
||
from_jid = jid.JID(from_jid_s) if from_jid_s else None
|
||
to_jid = jid.JID(to_jid_s) if to_jid_s else None
|
||
d = self.history_get(from_jid, to_jid, limit, between, filters, profile)
|
||
d.addCallback(self._history_get_serialise)
|
||
return d
|
||
|
||
def history_get(
|
||
self,
|
||
from_jid: jid.JID | None,
|
||
to_jid: jid.JID | None,
|
||
limit: int = C.HISTORY_LIMIT_NONE,
|
||
between: bool = True,
|
||
filters: dict[str, str | bool] | None = None,
|
||
profile: str = C.PROF_KEY_NONE,
|
||
) -> defer.Deferred[list]:
|
||
"""Retrieve messages in history
|
||
|
||
@param from_jid: source JID (full, or bare for catchall)
|
||
@param to_jid: dest JID (full, or bare for catchall)
|
||
@param limit: maximum number of messages to get:
|
||
- 0 for no message (returns the empty list)
|
||
- C.HISTORY_LIMIT_NONE or None for unlimited
|
||
- C.HISTORY_LIMIT_DEFAULT to use the HISTORY_LIMIT parameter value
|
||
@param between: confound source and dest (ignore the direction)
|
||
@param filters: pattern to filter the history results
|
||
(see bridge API for details)
|
||
@param profile: %(doc_profile)s
|
||
@return: list of message data as in [message_new]
|
||
"""
|
||
assert profile != C.PROF_KEY_NONE
|
||
if limit == C.HISTORY_LIMIT_DEFAULT:
|
||
limit = int(self.param_get_a(C.HISTORY_LIMIT, "General", profile_key=profile))
|
||
elif limit == C.HISTORY_LIMIT_NONE:
|
||
limit = None
|
||
if limit == 0:
|
||
return defer.succeed([])
|
||
return self.storage.history_get(
|
||
from_jid, to_jid, limit, between, filters, profile
|
||
)
|
||
|
||
def history_last_messages_metadata_get(
|
||
self,
|
||
options_s: str,
|
||
profile: str,
|
||
) -> defer.Deferred[str]:
|
||
"""Get last message timestamp per conversation peer.
|
||
|
||
@param options_s: Serialised HistoryLastOptions.
|
||
@param profile: Profile.
|
||
@return: Serialised HistoryLastResult.
|
||
"""
|
||
client = G.host.get_client(profile)
|
||
options = HistoryLastOptions.model_validate_json(options_s)
|
||
d = defer.ensureDeferred(
|
||
self.storage.history_last_messages_metadata_get(
|
||
client, options.types, options.since, options.limit
|
||
)
|
||
)
|
||
d.addCallback(
|
||
lambda last_data: HistoryLastResult(
|
||
peers={
|
||
peer: HistoryPeerInfo(
|
||
timestamp=info["timestamp"],
|
||
last_uid=info.get("uid", ""),
|
||
last_message=info.get("message", ""),
|
||
)
|
||
for peer, info in last_data.items()
|
||
}
|
||
).model_dump_json()
|
||
)
|
||
return d
|
||
|
||
## Displayed State ##
|
||
|
||
def _messages_display_state_set(
|
||
self, peer_jid_s: str, message_uid: str, profile: str
|
||
) -> defer.Deferred[None]:
|
||
client = G.host.get_client(profile)
|
||
return self.messages_display_state_set(client, jid.JID(peer_jid_s), message_uid)
|
||
|
||
def messages_display_state_set(
|
||
self, client: SatXMPPClient, peer_jid: jid.JID, message_uid: str
|
||
) -> defer.Deferred[None]:
|
||
"""Store the last displayed message uid for a peer.
|
||
|
||
Also resets the unread count for this peer and notifies frontends.
|
||
|
||
@param client: Client instance.
|
||
@param peer_jid: Bare JID of the peer (or room JID for MUC).
|
||
@param message_uid: Internal uid of the last displayed message.
|
||
"""
|
||
state = client.display_state.setdefault(peer_jid, {"count": 0, "last_uid": None})
|
||
old_count = state["count"]
|
||
state["count"] = 0
|
||
state["last_uid"] = message_uid
|
||
if old_count > 0:
|
||
self.host.bridge.messages_display_state_reset(
|
||
peer_jid.userhost(), client.profile
|
||
)
|
||
return defer.ensureDeferred(
|
||
self.storage.set_private_value(
|
||
C.KEY_LAST_DISP_MSG,
|
||
peer_jid.userhost(),
|
||
message_uid,
|
||
profile=client.profile,
|
||
)
|
||
)
|
||
|
||
def _messages_display_state_get(
|
||
self, jids_s: list[str], profile: str
|
||
) -> defer.Deferred[str]:
|
||
client = G.host.get_client(profile)
|
||
jids = [jid.JID(j) for j in jids_s] if jids_s else []
|
||
return self.messages_display_state_get(client, jids)
|
||
|
||
async def _init_display_state(self, client: SatXMPPClient) -> None:
|
||
"""Initialize display state from database.
|
||
|
||
This method should be called once on profile connection to populate client.display_state.
|
||
The data is then updated in-memory.
|
||
|
||
@param client: Client instance.
|
||
"""
|
||
uids = await self.storage.get_privates(
|
||
C.KEY_LAST_DISP_MSG, profile=client.profile
|
||
)
|
||
counts = await self.storage.unread_counts_get(client, C.KEY_LAST_DISP_MSG)
|
||
|
||
for peer_jid_s in uids:
|
||
if peer_jid_s not in counts:
|
||
log.warning(
|
||
f"Display state UID for {peer_jid_s!r} not found in history, "
|
||
"resetting display state for this peer"
|
||
)
|
||
|
||
client.display_state = {
|
||
jid.JID(peer_jid_s): {
|
||
"count": counts.get(peer_jid_s, 0),
|
||
"last_uid": uids.get(peer_jid_s) if peer_jid_s in counts else None,
|
||
}
|
||
for peer_jid_s in set(list(uids.keys()) + list(counts.keys()))
|
||
}
|
||
|
||
def messages_display_state_get(
|
||
self, client: SatXMPPClient, jids: list[jid.JID]
|
||
) -> defer.Deferred[str]:
|
||
"""Get display state for peers.
|
||
|
||
Returns cached unread count and last displayed UID per peer,
|
||
optionally filtered by JIDs. An empty list means return all peers.
|
||
|
||
@param client: Client instance.
|
||
@param jids: List of bare JIDs to filter, or empty list for all.
|
||
@return: Serialized MessageDisplayStatesResult.
|
||
"""
|
||
display_state = client.display_state
|
||
if jids:
|
||
display_state = {k: v for k, v in display_state.items() if k in jids}
|
||
|
||
from libervia.backend.models.core import (
|
||
MessageDisplayState,
|
||
MessageDisplayStatesResult,
|
||
)
|
||
|
||
result = MessageDisplayStatesResult(
|
||
{
|
||
peer: MessageDisplayState(
|
||
count=state["count"], last_uid=state["last_uid"]
|
||
)
|
||
for peer, state in display_state.items()
|
||
}
|
||
)
|
||
return defer.succeed(result.model_dump_json())
|
||
|
||
## Statuses ##
|
||
|
||
def _get_presence_statuses(self, profile_key):
|
||
ret = self.presence_statuses_get(profile_key)
|
||
return {entity.full(): data for entity, data in ret.items()}
|
||
|
||
def presence_statuses_get(self, profile_key):
|
||
"""Get all the presence statuses of a profile
|
||
|
||
@param profile_key: %(doc_profile_key)s
|
||
@return: presence data: key=entity JID, value=presence data for this entity
|
||
"""
|
||
client = self.host.get_client(profile_key)
|
||
profile_cache = self._get_profile_cache(client)
|
||
entities_presence = {}
|
||
|
||
for entity_jid, entity_data in profile_cache.items():
|
||
for resource, resource_data in entity_data.items():
|
||
full_jid = copy.copy(entity_jid)
|
||
full_jid.resource = resource
|
||
try:
|
||
presence_data = self.get_entity_datum(client, full_jid, "presence")
|
||
except KeyError:
|
||
continue
|
||
entities_presence.setdefault(entity_jid, {})[
|
||
resource or ""
|
||
] = presence_data
|
||
|
||
return entities_presence
|
||
|
||
def set_presence_status(self, entity_jid, show, priority, statuses, profile_key):
|
||
"""Change the presence status of an entity
|
||
|
||
@param entity_jid: jid.JID of the entity
|
||
@param show: show status
|
||
@param priority: priority
|
||
@param statuses: dictionary of statuses
|
||
@param profile_key: %(doc_profile_key)s
|
||
"""
|
||
client = self.host.get_client(profile_key)
|
||
presence_data = PresenceTuple(show, priority, statuses)
|
||
self.update_entity_data(client, entity_jid, "presence", presence_data)
|
||
if entity_jid.resource and show != C.PRESENCE_UNAVAILABLE:
|
||
# If a resource is available, bare jid should not have presence information
|
||
try:
|
||
self.del_entity_datum(client, entity_jid.userhostJID(), "presence")
|
||
except (KeyError, exceptions.UnknownEntityError):
|
||
pass
|
||
|
||
## Resources ##
|
||
|
||
def _get_all_resource(self, jid_s, profile_key):
|
||
client = self.host.get_client(profile_key)
|
||
jid_ = jid.JID(jid_s)
|
||
return self.get_all_resources(client, jid_)
|
||
|
||
def get_all_resources(self, client, entity_jid):
|
||
"""Return all resource from jid for which we have had data in this session
|
||
|
||
@param entity_jid: bare jid of the entity
|
||
return (set[unicode]): set of resources
|
||
|
||
@raise exceptions.UnknownEntityError: if entity is not in cache
|
||
@raise ValueError: entity_jid has a resource
|
||
"""
|
||
# FIXME: is there a need to keep cache data for resources which are not connected anymore?
|
||
if entity_jid.resource:
|
||
raise ValueError(
|
||
"get_all_resources must be used with a bare jid (got {})".format(
|
||
entity_jid
|
||
)
|
||
)
|
||
profile_cache = self._get_profile_cache(client)
|
||
try:
|
||
entity_data = profile_cache[entity_jid.userhostJID()]
|
||
except KeyError:
|
||
raise exceptions.UnknownEntityError(
|
||
"Entity {} not in cache".format(entity_jid)
|
||
)
|
||
resources = set(entity_data.keys())
|
||
resources.discard(None)
|
||
return resources
|
||
|
||
def get_available_resources(self, client, entity_jid):
|
||
"""Return available resource for entity_jid
|
||
|
||
This method differs from get_all_resources by returning only available resources
|
||
@param entity_jid: bare jid of the entit
|
||
return (list[unicode]): list of available resources
|
||
|
||
@raise exceptions.UnknownEntityError: if entity is not in cache
|
||
"""
|
||
available = []
|
||
for resource in self.get_all_resources(client, entity_jid):
|
||
full_jid = copy.copy(entity_jid)
|
||
full_jid.resource = resource
|
||
try:
|
||
presence_data = self.get_entity_datum(client, full_jid, "presence")
|
||
except KeyError:
|
||
log.debug("Can't get presence data for {}".format(full_jid))
|
||
else:
|
||
if presence_data.show != C.PRESENCE_UNAVAILABLE:
|
||
available.append(resource)
|
||
return available
|
||
|
||
def _get_main_resource(self, jid_s, profile_key):
|
||
client = self.host.get_client(profile_key)
|
||
jid_ = jid.JID(jid_s)
|
||
return self.main_resource_get(client, jid_) or ""
|
||
|
||
def main_resource_get(self, client, entity_jid):
|
||
"""Return the main resource used by an entity
|
||
|
||
@param entity_jid: bare entity jid
|
||
@return (unicode): main resource or None
|
||
"""
|
||
if entity_jid.resource:
|
||
raise ValueError(
|
||
"main_resource_get must be used with a bare jid (got {})".format(
|
||
entity_jid
|
||
)
|
||
)
|
||
try:
|
||
if self.host.plugins["XEP-0045"].is_joined_room(client, entity_jid):
|
||
return None # MUC rooms have no main resource
|
||
except KeyError: # plugin not found
|
||
pass
|
||
try:
|
||
resources = self.get_all_resources(client, entity_jid)
|
||
except exceptions.UnknownEntityError:
|
||
log.warning("Entity is not in cache, we can't find any resource")
|
||
return None
|
||
priority_resources = []
|
||
for resource in resources:
|
||
full_jid = copy.copy(entity_jid)
|
||
full_jid.resource = resource
|
||
try:
|
||
presence_data = self.get_entity_datum(client, full_jid, "presence")
|
||
except KeyError:
|
||
log.debug("No presence information for {}".format(full_jid))
|
||
continue
|
||
priority_resources.append((resource, presence_data.priority))
|
||
try:
|
||
return max(priority_resources, key=lambda res_tuple: res_tuple[1])[0]
|
||
except ValueError:
|
||
log.warning("No resource found at all for {}".format(entity_jid))
|
||
return None
|
||
|
||
## Entities data ##
|
||
|
||
def _get_profile_cache(self, client):
|
||
"""Check profile validity and return its cache
|
||
|
||
@param client: SatXMPPClient
|
||
@return (dict): profile cache
|
||
"""
|
||
return self._entities_cache[client.profile]
|
||
|
||
def set_signal_on_update(self, key, signal=True):
|
||
"""Set a signal flag on the key
|
||
|
||
When the key will be updated, a signal will be sent to frontends
|
||
@param key: key to signal
|
||
@param signal(boolean): if True, do the signal
|
||
"""
|
||
if signal:
|
||
self._key_signals.add(key)
|
||
else:
|
||
self._key_signals.discard(key)
|
||
|
||
def get_all_entities_iter(self, client, with_bare=False):
|
||
"""Return an iterator of full jids of all entities in cache
|
||
|
||
@param with_bare: if True, include bare jids
|
||
@return (list[unicode]): list of jids
|
||
"""
|
||
profile_cache = self._get_profile_cache(client)
|
||
# we construct a list of all known full jids (bare jid of entities x resources)
|
||
for bare_jid, entity_data in profile_cache.items():
|
||
for resource in entity_data.keys():
|
||
if resource is None:
|
||
continue
|
||
full_jid = copy.copy(bare_jid)
|
||
full_jid.resource = resource
|
||
yield full_jid
|
||
|
||
def update_entity_data(self, client, entity_jid, key, value, silent=False):
|
||
"""Set a misc data for an entity
|
||
|
||
If key was registered with set_signal_on_update, a signal will be sent to frontends
|
||
@param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of
|
||
all entities, C.ENTITY_ALL for all entities (all resources + bare jids)
|
||
@param key: key to set (eg: C.ENTITY_TYPE)
|
||
@param value: value for this key (eg: C.ENTITY_TYPE_MUC)
|
||
@param silent(bool): if True, doesn't send signal to frontend, even if there is a
|
||
signal flag (see set_signal_on_update)
|
||
"""
|
||
profile_cache = self._get_profile_cache(client)
|
||
if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
|
||
entities = self.get_all_entities_iter(client, entity_jid == C.ENTITY_ALL)
|
||
else:
|
||
entities = (entity_jid,)
|
||
|
||
for jid_ in entities:
|
||
entity_data = profile_cache.setdefault(jid_.userhostJID(), {}).setdefault(
|
||
jid_.resource, {}
|
||
)
|
||
|
||
entity_data[key] = value
|
||
if key in self._key_signals and not silent:
|
||
self.host.bridge.entity_data_updated(
|
||
jid_.full(), key, data_format.serialise(value), client.profile
|
||
)
|
||
|
||
def del_entity_datum(self, client, entity_jid, key):
|
||
"""Delete a data for an entity
|
||
|
||
@param entity_jid: JID of the entity, C.ENTITY_ALL_RESOURCES for all resources of all entities,
|
||
C.ENTITY_ALL for all entities (all resources + bare jids)
|
||
@param key: key to delete (eg: C.ENTITY_TYPE)
|
||
|
||
@raise exceptions.UnknownEntityError: if entity is not in cache
|
||
@raise KeyError: key is not in cache
|
||
"""
|
||
profile_cache = self._get_profile_cache(client)
|
||
if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
|
||
entities = self.get_all_entities_iter(client, entity_jid == C.ENTITY_ALL)
|
||
else:
|
||
entities = (entity_jid,)
|
||
|
||
for jid_ in entities:
|
||
try:
|
||
entity_data = profile_cache[jid_.userhostJID()][jid_.resource]
|
||
except KeyError:
|
||
raise exceptions.UnknownEntityError("Entity {} not in cache".format(jid_))
|
||
try:
|
||
del entity_data[key]
|
||
except KeyError as e:
|
||
if entity_jid in (C.ENTITY_ALL_RESOURCES, C.ENTITY_ALL):
|
||
continue # we ignore KeyError when deleting keys from several entities
|
||
else:
|
||
raise e
|
||
|
||
def _get_entities_data(self, entities_jids, keys_list, profile_key):
|
||
client = self.host.get_client(profile_key)
|
||
ret = self.entities_data_get(
|
||
client, [jid.JID(jid_) for jid_ in entities_jids], keys_list
|
||
)
|
||
return {
|
||
jid_.full(): {k: data_format.serialise(v) for k, v in data.items()}
|
||
for jid_, data in ret.items()
|
||
}
|
||
|
||
def entities_data_get(self, client, entities_jids, keys_list=None):
|
||
"""Get a list of cached values for several entities at once
|
||
|
||
@param entities_jids: jids of the entities, or empty list for all entities in cache
|
||
@param keys_list (iterable,None): list of keys to get, None for everything
|
||
@param profile_key: %(doc_profile_key)s
|
||
@return: dict withs values for each key in keys_list.
|
||
if there is no value of a given key, resulting dict will
|
||
have nothing with that key nether
|
||
if an entity doesn't exist in cache, it will not appear
|
||
in resulting dict
|
||
|
||
@raise exceptions.UnknownEntityError: if entity is not in cache
|
||
"""
|
||
|
||
def fill_entity_data(entity_cache_data):
|
||
entity_data = {}
|
||
if keys_list is None:
|
||
entity_data = entity_cache_data
|
||
else:
|
||
for key in keys_list:
|
||
try:
|
||
entity_data[key] = entity_cache_data[key]
|
||
except KeyError:
|
||
continue
|
||
return entity_data
|
||
|
||
profile_cache = self._get_profile_cache(client)
|
||
ret_data = {}
|
||
if entities_jids:
|
||
for entity in entities_jids:
|
||
try:
|
||
entity_cache_data = profile_cache[entity.userhostJID()][
|
||
entity.resource
|
||
]
|
||
except KeyError:
|
||
continue
|
||
ret_data[entity.full()] = fill_entity_data(entity_cache_data, keys_list)
|
||
else:
|
||
for bare_jid, data in profile_cache.items():
|
||
for resource, entity_cache_data in data.items():
|
||
full_jid = copy.copy(bare_jid)
|
||
full_jid.resource = resource
|
||
ret_data[full_jid] = fill_entity_data(entity_cache_data)
|
||
|
||
return ret_data
|
||
|
||
def _get_entity_data(self, entity_jid_s, keys_list=None, profile=C.PROF_KEY_NONE):
|
||
return self.entity_data_get(
|
||
self.host.get_client(profile), jid.JID(entity_jid_s), keys_list
|
||
)
|
||
|
||
def entity_data_get(self, client, entity_jid, keys_list=None):
|
||
"""Get a list of cached values for entity
|
||
|
||
@param entity_jid: JID of the entity
|
||
@param keys_list (iterable,None): list of keys to get, None for everything
|
||
@param profile_key: %(doc_profile_key)s
|
||
@return: dict withs values for each key in keys_list.
|
||
if there is no value of a given key, resulting dict will
|
||
have nothing with that key nether
|
||
|
||
@raise exceptions.UnknownEntityError: if entity is not in cache
|
||
"""
|
||
profile_cache = self._get_profile_cache(client)
|
||
try:
|
||
entity_data = profile_cache[entity_jid.userhostJID()][entity_jid.resource]
|
||
except KeyError:
|
||
raise exceptions.UnknownEntityError(
|
||
"Entity {} not in cache (was requesting {})".format(entity_jid, keys_list)
|
||
)
|
||
if keys_list is None:
|
||
return entity_data
|
||
|
||
return {key: entity_data[key] for key in keys_list if key in entity_data}
|
||
|
||
def get_entity_datum(self, client, entity_jid, key):
|
||
"""Get a datum from entity
|
||
|
||
@param entity_jid: JID of the entity
|
||
@param key: key to get
|
||
@return: requested value
|
||
|
||
@raise exceptions.UnknownEntityError: if entity is not in cache
|
||
@raise KeyError: if there is no value for this key and this entity
|
||
"""
|
||
return self.entity_data_get(client, entity_jid, (key,))[key]
|
||
|
||
def del_entity_cache(
|
||
self, entity_jid, delete_all_resources=True, profile_key=C.PROF_KEY_NONE
|
||
):
|
||
"""Remove all cached data for entity
|
||
|
||
@param entity_jid: JID of the entity to delete
|
||
@param delete_all_resources: if True also delete all known resources from cache (a bare jid must be given in this case)
|
||
@param profile_key: %(doc_profile_key)s
|
||
|
||
@raise exceptions.UnknownEntityError: if entity is not in cache
|
||
"""
|
||
client = self.host.get_client(profile_key)
|
||
profile_cache = self._get_profile_cache(client)
|
||
|
||
if delete_all_resources:
|
||
if entity_jid.resource:
|
||
raise ValueError(_("Need a bare jid to delete all resources"))
|
||
try:
|
||
del profile_cache[entity_jid]
|
||
except KeyError:
|
||
raise exceptions.UnknownEntityError(
|
||
"Entity {} not in cache".format(entity_jid)
|
||
)
|
||
else:
|
||
try:
|
||
del profile_cache[entity_jid.userhostJID()][entity_jid.resource]
|
||
except KeyError:
|
||
raise exceptions.UnknownEntityError(
|
||
"Entity {} not in cache".format(entity_jid)
|
||
)
|
||
|
||
## Encryption ##
|
||
|
||
def encrypt_value(self, client: SatXMPPEntity, value: str) -> str:
|
||
"""Encrypt a value for the given profile.
|
||
|
||
I uses a personal key derived from password. The personal key is stable on profile
|
||
password change, so there is no need to re-encrypt.
|
||
|
||
The personal key must be loaded already in the profile session, that should be the
|
||
case if the profile is already authenticated (session started).
|
||
|
||
@param client: client instance.
|
||
Its profile's personal key will be used as encryption key.
|
||
@param value: the value to encrypt
|
||
@return: base-64 encoded encrypted value
|
||
"""
|
||
session = self.auth_sessions.profile_get_unique(client.profile)
|
||
if session is None or (personal_key := session.get(C.MEMORY_CRYPTO_KEY)) is None:
|
||
raise exceptions.InternalError(
|
||
f"Trying to encrypt a value for {client.profile} while the personal key "
|
||
"is undefined!"
|
||
)
|
||
return BlockCipher.encrypt(personal_key, value)
|
||
|
||
def decrypt_value(self, client: SatXMPPEntity, encrypted: str) -> str:
|
||
"""Decrypt a value for the given profile.
|
||
|
||
See [encrypt_value].
|
||
|
||
@param client: client instance.
|
||
Its profile's personal key will be used as encryption key.
|
||
@param encrypted: base-64 encoded encrypted value
|
||
@return: decrypted value
|
||
@raise exceptions.InternalError: personal key is not loaded
|
||
"""
|
||
return self.decrypt_value_by_profile(encrypted, client.profile)
|
||
|
||
def decrypt_value_by_profile(self, encrypted: str, profile: str) -> str:
|
||
"""Decrypt a value for the given profile by its name.
|
||
|
||
Useful when a client is not yet available, but the profile session has been
|
||
started.
|
||
|
||
@param encrypted: base-64 encoded encrypted value
|
||
@param profile: profile name
|
||
@return: decrypted value
|
||
@raise exceptions.InternalError: personal key is not loaded
|
||
"""
|
||
session = self.auth_sessions.profile_get_unique(profile)
|
||
if session is None or (personal_key := session.get(C.MEMORY_CRYPTO_KEY)) is None:
|
||
raise exceptions.InternalError(
|
||
f"Trying to decrypt a value for {profile} while the personal key "
|
||
"is undefined!"
|
||
)
|
||
return BlockCipher.decrypt(personal_key, encrypted)
|
||
|
||
def encrypt_personal_data(self, data_key, data_value, crypto_key, profile):
|
||
"""Re-encrypt a personal data (saved to a PersistentDict).
|
||
|
||
@param data_key: key for the individual PersistentDict instance
|
||
@param data_value: the value to be encrypted
|
||
@param crypto_key: the key to encrypt the value
|
||
@param profile: %(profile_doc)s
|
||
@return: a deferred None value
|
||
"""
|
||
|
||
def got_ind_memory(data):
|
||
data[data_key] = BlockCipher.encrypt(crypto_key, data_value)
|
||
return data.force(data_key)
|
||
|
||
def done(__):
|
||
log.debug(
|
||
_("Personal data (%(ns)s, %(key)s) has been successfuly encrypted")
|
||
% {"ns": C.MEMORY_CRYPTO_NAMESPACE, "key": data_key}
|
||
)
|
||
|
||
d = PersistentDict(C.MEMORY_CRYPTO_NAMESPACE, profile).load()
|
||
return d.addCallback(got_ind_memory).addCallback(done)
|
||
|
||
## Subscription requests ##
|
||
|
||
def add_waiting_sub(self, type_, entity_jid, profile_key):
|
||
"""Called when a subcription request is received"""
|
||
profile = self.get_profile_name(profile_key)
|
||
assert profile
|
||
if profile not in self.subscriptions:
|
||
self.subscriptions[profile] = {}
|
||
self.subscriptions[profile][entity_jid] = type_
|
||
|
||
def del_waiting_sub(self, entity_jid, profile_key):
|
||
"""Called when a subcription request is finished"""
|
||
profile = self.get_profile_name(profile_key)
|
||
assert profile
|
||
if profile in self.subscriptions and entity_jid in self.subscriptions[profile]:
|
||
del self.subscriptions[profile][entity_jid]
|
||
|
||
def sub_waiting_get(self, profile_key):
|
||
"""Called to get a list of currently waiting subscription requests"""
|
||
profile = self.get_profile_name(profile_key)
|
||
if not profile:
|
||
log.error(_("Asking waiting subscriptions for a non-existant profile"))
|
||
return {}
|
||
if profile not in self.subscriptions:
|
||
return {}
|
||
|
||
return self.subscriptions[profile]
|
||
|
||
## Parameters ##
|
||
|
||
def get_string_param_a(
|
||
self, name, category, attr="value", profile_key=C.PROF_KEY_NONE
|
||
):
|
||
return self.params.get_string_param_a(name, category, attr, profile_key)
|
||
|
||
def param_get_a(self, name, category, attr="value", profile_key=C.PROF_KEY_NONE):
|
||
return self.params.param_get_a(name, category, attr, profile_key=profile_key)
|
||
|
||
def param_get_a_async(
|
||
self,
|
||
name,
|
||
category,
|
||
attr="value",
|
||
security_limit=C.NO_SECURITY_LIMIT,
|
||
profile_key=C.PROF_KEY_NONE,
|
||
):
|
||
return self.params.param_get_a_async(
|
||
name, category, attr, security_limit, profile_key
|
||
)
|
||
|
||
def _get_params_values_from_category(
|
||
self, category, security_limit, app, extra_s, profile_key
|
||
):
|
||
return self.params._get_params_values_from_category(
|
||
category, security_limit, app, extra_s, profile_key
|
||
)
|
||
|
||
def async_get_string_param_a(
|
||
self,
|
||
name,
|
||
category,
|
||
attribute="value",
|
||
security_limit=C.NO_SECURITY_LIMIT,
|
||
profile_key=C.PROF_KEY_NONE,
|
||
):
|
||
|
||
profile = self.get_profile_name(profile_key)
|
||
return defer.ensureDeferred(
|
||
self.params.async_get_string_param_a(
|
||
name, category, attribute, security_limit, profile
|
||
)
|
||
)
|
||
|
||
def _get_params_ui(self, security_limit, app, extra_s, profile_key):
|
||
return self.params._get_params_ui(security_limit, app, extra_s, profile_key)
|
||
|
||
def params_categories_get(self):
|
||
return self.params.params_categories_get()
|
||
|
||
def param_set(
|
||
self,
|
||
name,
|
||
value,
|
||
category,
|
||
security_limit=C.NO_SECURITY_LIMIT,
|
||
profile_key=C.PROF_KEY_NONE,
|
||
):
|
||
return self.params.param_set(name, value, category, security_limit, profile_key)
|
||
|
||
def update_params(self, xml):
|
||
return self.params.update_params(xml)
|
||
|
||
def params_register_app(self, xml, security_limit=C.NO_SECURITY_LIMIT, app=""):
|
||
return self.params.params_register_app(xml, security_limit, app)
|
||
|
||
def set_default(self, name, category, callback, errback=None):
|
||
return self.params.set_default(name, category, callback, errback)
|
||
|
||
## Private Data ##
|
||
|
||
def _private_data_set(self, namespace, key, data_s, profile_key):
|
||
client = self.host.get_client(profile_key)
|
||
# we accept any type
|
||
data = data_format.deserialise(data_s, type_check=None)
|
||
return defer.ensureDeferred(
|
||
self.storage.set_private_value(
|
||
namespace, key, data, binary=True, profile=client.profile
|
||
)
|
||
)
|
||
|
||
def _private_data_get(self, namespace, key, profile_key):
|
||
client = self.host.get_client(profile_key)
|
||
d = defer.ensureDeferred(
|
||
self.storage.get_privates(
|
||
namespace, [key], binary=True, profile=client.profile
|
||
)
|
||
)
|
||
d.addCallback(lambda data_dict: data_format.serialise(data_dict.get(key)))
|
||
return d
|
||
|
||
def _private_data_delete(self, namespace, key, profile_key):
|
||
client = self.host.get_client(profile_key)
|
||
return defer.ensureDeferred(
|
||
self.storage.del_private_value(
|
||
namespace, key, binary=True, profile=client.profile
|
||
)
|
||
)
|
||
|
||
## Files ##
|
||
|
||
def check_file_permission(
|
||
self,
|
||
file_data: dict,
|
||
peer_jid: Optional[jid.JID],
|
||
perms_to_check: Optional[Tuple[str]],
|
||
set_affiliation: bool = False,
|
||
) -> None:
|
||
"""Check that an entity has the right permission on a file
|
||
|
||
@param file_data: data of one file, as returned by get_files
|
||
@param peer_jid: entity trying to access the file
|
||
@param perms_to_check: permissions to check
|
||
tuple of C.ACCESS_PERM_*
|
||
@param check_parents: if True, also check all parents until root node
|
||
@parma set_affiliation: if True, "affiliation" metadata will be set
|
||
@raise exceptions.PermissionError: peer_jid doesn't have all permission
|
||
in perms_to_check for file_data
|
||
@raise exceptions.InternalError: perms_to_check is invalid
|
||
"""
|
||
# TODO: knowing if user is owner is not enough, we need to check permission
|
||
# to see if user can modify/delete files, and set corresponding affiliation (publisher, member)
|
||
if peer_jid is None and perms_to_check is None:
|
||
return
|
||
peer_jid = peer_jid.userhostJID()
|
||
if peer_jid == file_data["owner"]:
|
||
if set_affiliation:
|
||
file_data["affiliation"] = "owner"
|
||
# the owner has all rights, nothing to check
|
||
return
|
||
if not C.ACCESS_PERMS.issuperset(perms_to_check):
|
||
raise exceptions.InternalError(_("invalid permission"))
|
||
|
||
for perm in perms_to_check:
|
||
# we check each perm and raise PermissionError as soon as one condition is not valid
|
||
# we must never return here, we only return after the loop if nothing was blocking the access
|
||
try:
|
||
perm_data = file_data["access"][perm]
|
||
perm_type = perm_data["type"]
|
||
except KeyError:
|
||
# No permission is set.
|
||
# If we are in a root file/directory, we deny access
|
||
# otherwise, we use public permission, as the parent directory will
|
||
# block anyway, this avoid to have to recursively change permissions for
|
||
# all sub directories/files when modifying a permission
|
||
if not file_data.get("parent"):
|
||
raise exceptions.PermissionError()
|
||
else:
|
||
perm_type = C.ACCESS_TYPE_PUBLIC
|
||
if perm_type == C.ACCESS_TYPE_PUBLIC:
|
||
continue
|
||
elif perm_type == C.ACCESS_TYPE_WHITELIST:
|
||
try:
|
||
jids = perm_data["jids"]
|
||
except KeyError:
|
||
raise exceptions.PermissionError()
|
||
if peer_jid.full() in jids:
|
||
continue
|
||
else:
|
||
raise exceptions.PermissionError()
|
||
else:
|
||
raise exceptions.InternalError(
|
||
_("unknown access type: {type}").format(type=perm_type)
|
||
)
|
||
|
||
async def check_permission_to_root(self, client, file_data, peer_jid, perms_to_check):
|
||
"""do check_file_permission on file_data and all its parents until root"""
|
||
current = file_data
|
||
while True:
|
||
self.check_file_permission(current, peer_jid, perms_to_check)
|
||
parent = current["parent"]
|
||
if not parent:
|
||
break
|
||
files_data = await self.get_files(
|
||
client, peer_jid=None, file_id=parent, perms_to_check=None
|
||
)
|
||
try:
|
||
current = files_data[0]
|
||
except IndexError:
|
||
raise exceptions.DataError("Missing parent")
|
||
|
||
async def _get_parent_dir(
|
||
self, client, path, parent, namespace, owner, peer_jid, perms_to_check
|
||
):
|
||
"""Retrieve parent node from a path, or last existing directory
|
||
|
||
each directory of the path will be retrieved, until the last existing one
|
||
@return (tuple[unicode, list[unicode])): parent, remaining path elements:
|
||
- parent is the id of the last retrieved directory (or u'' for root)
|
||
- remaining path elements are the directories which have not been retrieved
|
||
(i.e. which don't exist)
|
||
"""
|
||
# if path is set, we have to retrieve parent directory of the file(s) from it
|
||
if parent is not None:
|
||
raise exceptions.ConflictError(
|
||
_("You can't use path and parent at the same time")
|
||
)
|
||
path_elts = [_f for _f in path.split("/") if _f]
|
||
if {"..", "."}.intersection(path_elts):
|
||
raise ValueError(_('".." or "." can\'t be used in path'))
|
||
|
||
# we retrieve all directories from path until we get the parent container
|
||
# non existing directories will be created
|
||
parent = ""
|
||
for idx, path_elt in enumerate(path_elts):
|
||
directories = await self.storage.get_files(
|
||
client,
|
||
parent=parent,
|
||
type_=C.FILE_TYPE_DIRECTORY,
|
||
name=path_elt,
|
||
namespace=namespace,
|
||
owner=owner,
|
||
)
|
||
if not directories:
|
||
return (parent, path_elts[idx:])
|
||
# from this point, directories don't exist anymore, we have to create them
|
||
elif len(directories) > 1:
|
||
raise exceptions.InternalError(
|
||
_("Several directories found, this should not happen")
|
||
)
|
||
else:
|
||
directory = directories[0]
|
||
self.check_file_permission(directory, peer_jid, perms_to_check)
|
||
parent = directory["id"]
|
||
return (parent, [])
|
||
|
||
def get_file_affiliations(self, file_data: dict) -> Dict[jid.JID, str]:
|
||
"""Convert file access to pubsub like affiliations"""
|
||
affiliations = {}
|
||
access_data = file_data["access"]
|
||
|
||
read_data = access_data.get(C.ACCESS_PERM_READ, {})
|
||
if read_data.get("type") == C.ACCESS_TYPE_WHITELIST:
|
||
for entity_jid_s in read_data["jids"]:
|
||
entity_jid = jid.JID(entity_jid_s)
|
||
affiliations[entity_jid] = "member"
|
||
|
||
write_data = access_data.get(C.ACCESS_PERM_WRITE, {})
|
||
if write_data.get("type") == C.ACCESS_TYPE_WHITELIST:
|
||
for entity_jid_s in write_data["jids"]:
|
||
entity_jid = jid.JID(entity_jid_s)
|
||
affiliations[entity_jid] = "publisher"
|
||
|
||
owner = file_data.get("owner")
|
||
if owner:
|
||
affiliations[owner] = "owner"
|
||
|
||
return affiliations
|
||
|
||
def _set_file_affiliations_update(
|
||
self, access: dict, file_data: dict, affiliations: Dict[jid.JID, str]
|
||
) -> None:
|
||
read_data = access.setdefault(C.ACCESS_PERM_READ, {})
|
||
if read_data.get("type") != C.ACCESS_TYPE_WHITELIST:
|
||
read_data["type"] = C.ACCESS_TYPE_WHITELIST
|
||
if "jids" not in read_data:
|
||
read_data["jids"] = []
|
||
read_whitelist = read_data["jids"]
|
||
write_data = access.setdefault(C.ACCESS_PERM_WRITE, {})
|
||
if write_data.get("type") != C.ACCESS_TYPE_WHITELIST:
|
||
write_data["type"] = C.ACCESS_TYPE_WHITELIST
|
||
if "jids" not in write_data:
|
||
write_data["jids"] = []
|
||
write_whitelist = write_data["jids"]
|
||
for entity_jid, affiliation in affiliations.items():
|
||
entity_jid_s = entity_jid.full()
|
||
if affiliation == "none":
|
||
try:
|
||
read_whitelist.remove(entity_jid_s)
|
||
except ValueError:
|
||
log.warning(
|
||
"removing affiliation from an entity without read permission: "
|
||
f"{entity_jid}"
|
||
)
|
||
try:
|
||
write_whitelist.remove(entity_jid_s)
|
||
except ValueError:
|
||
pass
|
||
elif affiliation == "publisher":
|
||
if entity_jid_s not in read_whitelist:
|
||
read_whitelist.append(entity_jid_s)
|
||
if entity_jid_s not in write_whitelist:
|
||
write_whitelist.append(entity_jid_s)
|
||
elif affiliation == "member":
|
||
if entity_jid_s not in read_whitelist:
|
||
read_whitelist.append(entity_jid_s)
|
||
try:
|
||
write_whitelist.remove(entity_jid_s)
|
||
except ValueError:
|
||
pass
|
||
elif affiliation == "owner":
|
||
raise NotImplementedError('"owner" affiliation can\'t be set')
|
||
else:
|
||
raise ValueError(f"unknown affiliation: {affiliation!r}")
|
||
|
||
async def set_file_affiliations(
|
||
self, client, file_data: dict, affiliations: Dict[jid.JID, str]
|
||
) -> None:
|
||
"""Apply pubsub like affiliation to file_data
|
||
|
||
Affiliations are converted to access types, then set in a whitelist.
|
||
Affiliation are mapped as follow:
|
||
- "owner" can't be set (for now)
|
||
- "publisher" gives read and write permissions
|
||
- "member" gives read permission only
|
||
- "none" removes both read and write permissions
|
||
"""
|
||
file_id = file_data["id"]
|
||
await self.file_update(
|
||
file_id,
|
||
"access",
|
||
update_cb=partial(
|
||
self._set_file_affiliations_update,
|
||
file_data=file_data,
|
||
affiliations=affiliations,
|
||
),
|
||
)
|
||
|
||
def _set_file_access_model_update(
|
||
self, access: dict, file_data: dict, access_model: str
|
||
) -> None:
|
||
read_data = access.setdefault(C.ACCESS_PERM_READ, {})
|
||
if access_model == "open":
|
||
requested_type = C.ACCESS_TYPE_PUBLIC
|
||
elif access_model == "whitelist":
|
||
requested_type = C.ACCESS_TYPE_WHITELIST
|
||
else:
|
||
raise ValueError(f"unknown access model: {access_model}")
|
||
|
||
read_data["type"] = requested_type
|
||
if requested_type == C.ACCESS_TYPE_WHITELIST and "jids" not in read_data:
|
||
read_data["jids"] = []
|
||
|
||
async def set_file_access_model(
|
||
self,
|
||
client,
|
||
file_data: dict,
|
||
access_model: str,
|
||
) -> None:
|
||
"""Apply pubsub like access_model to file_data
|
||
|
||
Only 2 access models are supported so far:
|
||
- "open": set public access to file/dir
|
||
- "whitelist": set whitelist to file/dir
|
||
"""
|
||
file_id = file_data["id"]
|
||
await self.file_update(
|
||
file_id,
|
||
"access",
|
||
update_cb=partial(
|
||
self._set_file_access_model_update,
|
||
file_data=file_data,
|
||
access_model=access_model,
|
||
),
|
||
)
|
||
|
||
def get_files_owner(
|
||
self,
|
||
client,
|
||
owner: Optional[jid.JID],
|
||
peer_jid: Optional[jid.JID],
|
||
file_id: Optional[str] = None,
|
||
parent: Optional[str] = None,
|
||
) -> jid.JID:
|
||
"""Get owner to use for a file operation
|
||
|
||
if owner is not explicitely set, a suitable one will be used (client.jid for
|
||
clients, peer_jid for components).
|
||
@raise exception.InternalError: we are one a component, and neither owner nor
|
||
peer_jid are set
|
||
"""
|
||
if owner is not None:
|
||
return owner.userhostJID()
|
||
if client is None:
|
||
# client may be None when looking for file with public_id
|
||
return None
|
||
if file_id or parent:
|
||
# owner has already been filtered on parent file
|
||
return None
|
||
if not client.is_component:
|
||
return client.jid.userhostJID()
|
||
if peer_jid is None:
|
||
raise exceptions.InternalError(
|
||
"Owner must be set for component if peer_jid is None"
|
||
)
|
||
return peer_jid.userhostJID()
|
||
|
||
async def get_files(
|
||
self,
|
||
client,
|
||
peer_jid,
|
||
file_id=None,
|
||
version=None,
|
||
parent=None,
|
||
path=None,
|
||
type_=None,
|
||
file_hash=None,
|
||
hash_algo=None,
|
||
name=None,
|
||
namespace=None,
|
||
mime_type=None,
|
||
public_id=None,
|
||
owner=None,
|
||
access=None,
|
||
projection=None,
|
||
unique=False,
|
||
perms_to_check=(C.ACCESS_PERM_READ,),
|
||
):
|
||
"""Retrieve files with with given filters
|
||
|
||
@param peer_jid(jid.JID, None): jid trying to access the file
|
||
needed to check permission.
|
||
Use None to ignore permission (perms_to_check must be None too)
|
||
@param file_id(unicode, None): id of the file
|
||
None to ignore
|
||
@param version(unicode, None): version of the file
|
||
None to ignore
|
||
empty string to look for current version
|
||
@param parent(unicode, None): id of the directory containing the files
|
||
None to ignore
|
||
empty string to look for root files/directories
|
||
@param path(Path, unicode, None): path to the directory containing the files
|
||
@param type_(unicode, None): type of file filter, can be one of C.FILE_TYPE_*
|
||
@param file_hash(unicode, None): hash of the file to retrieve
|
||
@param hash_algo(unicode, None): algorithm use for file_hash
|
||
@param name(unicode, None): name of the file to retrieve
|
||
@param namespace(unicode, None): namespace of the files to retrieve
|
||
@param mime_type(unicode, None): filter on this mime type
|
||
@param public_id(unicode, None): filter on this public id
|
||
@param owner(jid.JID, None): if not None, only get files from this owner
|
||
@param access(dict, None): get file with given access (see [set_file])
|
||
@param projection(list[unicode], None): name of columns to retrieve
|
||
None to retrieve all
|
||
@param unique(bool): if True will remove duplicates
|
||
@param perms_to_check(tuple[unicode],None): permission to check
|
||
must be a tuple of C.ACCESS_PERM_* or None
|
||
if None, permission will no be checked (peer_jid must be None too in this
|
||
case)
|
||
other params are the same as for [set_file]
|
||
@return (list[dict]): files corresponding to filters
|
||
@raise exceptions.NotFound: parent directory not found (when path is specified)
|
||
@raise exceptions.PermissionError: peer_jid can't use perms_to_check for one of
|
||
the file
|
||
on the path
|
||
"""
|
||
if peer_jid is None and perms_to_check or perms_to_check is None and peer_jid:
|
||
raise exceptions.InternalError(
|
||
"if you want to disable permission check, both peer_jid and "
|
||
"perms_to_check must be None"
|
||
)
|
||
owner = self.get_files_owner(client, owner, peer_jid, file_id, parent)
|
||
if path is not None:
|
||
path = str(path)
|
||
# permission are checked by _get_parent_dir
|
||
parent, remaining_path_elts = await self._get_parent_dir(
|
||
client, path, parent, namespace, owner, peer_jid, perms_to_check
|
||
)
|
||
if remaining_path_elts:
|
||
# if we have remaining path elements,
|
||
# the parent directory is not found
|
||
raise exceptions.NotFound()
|
||
if parent and peer_jid:
|
||
# if parent is given directly and permission check is requested,
|
||
# we need to check all the parents
|
||
parent_data = await self.storage.get_files(client, file_id=parent)
|
||
try:
|
||
parent_data = parent_data[0]
|
||
except IndexError:
|
||
raise exceptions.DataError("mising parent")
|
||
await self.check_permission_to_root(
|
||
client, parent_data, peer_jid, perms_to_check
|
||
)
|
||
|
||
files = await self.storage.get_files(
|
||
client,
|
||
file_id=file_id,
|
||
version=version,
|
||
parent=parent,
|
||
type_=type_,
|
||
file_hash=file_hash,
|
||
hash_algo=hash_algo,
|
||
name=name,
|
||
namespace=namespace,
|
||
mime_type=mime_type,
|
||
public_id=public_id,
|
||
owner=owner,
|
||
access=access,
|
||
projection=projection,
|
||
unique=unique,
|
||
)
|
||
|
||
if peer_jid:
|
||
# if permission are checked, we must remove all file that user can't access
|
||
to_remove = []
|
||
for file_data in files:
|
||
try:
|
||
self.check_file_permission(
|
||
file_data, peer_jid, perms_to_check, set_affiliation=True
|
||
)
|
||
except exceptions.PermissionError:
|
||
to_remove.append(file_data)
|
||
for file_data in to_remove:
|
||
files.remove(file_data)
|
||
return files
|
||
|
||
async def set_file(
|
||
self,
|
||
client: SatXMPPEntity,
|
||
name: str,
|
||
file_id: str | None = None,
|
||
version: str = "",
|
||
parent: str | None = None,
|
||
path: str | None = None,
|
||
type_: str = C.FILE_TYPE_FILE,
|
||
file_hash: str | None = None,
|
||
hash_algo: str | None = None,
|
||
size: int | None = None,
|
||
namespace: str | None = None,
|
||
mime_type: str | None = None,
|
||
public_id: str | None = None,
|
||
created: float | None = None,
|
||
modified: int | None = None,
|
||
owner: jid.JID | None = None,
|
||
access: dict | None = None,
|
||
extra: dict | None = None,
|
||
peer_jid: Any | None = None,
|
||
perms_to_check: tuple[str] | None = (C.ACCESS_PERM_WRITE,),
|
||
) -> None:
|
||
"""Set a file metadata.
|
||
|
||
@param name: basename of the file
|
||
@param file_id: unique id of the file
|
||
@param version: version of this file, empty string for current version or when
|
||
there is no versioning
|
||
@param parent: id of the directory containing the files
|
||
@param path: virtual path of the file in the namespace, if set, parent must be
|
||
None. All intermediate directories will be created if needed, using current
|
||
access.
|
||
@param type_: type of file filter, can be one of C.FILE_TYPE_*
|
||
@param file_hash: unique hash of the payload
|
||
@param hash_algo: algorithm used for hashing the file (usually sha-256)
|
||
@param size: size in bytes
|
||
@param namespace: identifier (human readable is better) to group files. For
|
||
instance, namespace could be used to group files in a specific photo album.
|
||
@param mime_type: MIME type of the file, or None if not known/guessed
|
||
@param public_id: id used to share publicly the file via HTTP
|
||
@param created: UNIX time of creation
|
||
@param modified: UNIX time of last modification, or None to use created date
|
||
@param owner: jid of the owner of the file (mainly useful for component). will be
|
||
used to check permission (only bare jid is used, don't use with MUC). Use None
|
||
to ignore permission (perms_to_check must be None too)
|
||
@param access: serialisable dictionary with access rules.
|
||
None (or empty dict) to use private access, i.e. allow only profile's jid to
|
||
access the file.
|
||
Key can be on on C.ACCESS_PERM_*, then a sub dictionary with a type key is
|
||
used (one of C.ACCESS_TYPE_*).
|
||
According to type, extra keys can be used:
|
||
- C.ACCESS_TYPE_PUBLIC: the permission is granted for everybody
|
||
- C.ACCESS_TYPE_WHITELIST: the permission is granted for jids (as unicode)
|
||
in the 'jids' key. will be encoded to json in database
|
||
@param extra: serialisable dictionary of any extra data. will be encoded to json
|
||
in database
|
||
@param perms_to_check: permission to check. must be a tuple of C.ACCESS_PERM_* or
|
||
None. if None, permission will not be checked (peer_jid must be None too in
|
||
this case)
|
||
"""
|
||
if "/" in name:
|
||
raise ValueError('name must not contain a slash ("/")')
|
||
if file_id is None:
|
||
file_id = shortuuid.uuid()
|
||
if (
|
||
file_hash is not None
|
||
and hash_algo is None
|
||
or hash_algo is not None
|
||
and file_hash is None
|
||
):
|
||
raise ValueError("file_hash and hash_algo must be set at the same time")
|
||
if mime_type is None:
|
||
mime_type, __ = mimetypes.guess_type(name)
|
||
else:
|
||
mime_type = mime_type.lower()
|
||
if public_id is not None:
|
||
assert len(public_id) > 0
|
||
if created is None:
|
||
created = time.time()
|
||
if namespace is not None:
|
||
namespace = namespace.strip() or None
|
||
if type_ == C.FILE_TYPE_DIRECTORY:
|
||
if any((version, file_hash, size, mime_type)):
|
||
raise ValueError(
|
||
"version, file_hash, size and mime_type can't be set for a directory"
|
||
)
|
||
owner = self.get_files_owner(client, owner, peer_jid, file_id, parent)
|
||
|
||
if path is not None:
|
||
# We use a lock to avoid race condition leading to duplicate directories with
|
||
# same name and parent.
|
||
await self._file_path_lock.acquire()
|
||
try:
|
||
|
||
path = str(path)
|
||
# _get_parent_dir will check permissions if peer_jid is set, so we use owner
|
||
parent, remaining_path_elts = await self._get_parent_dir(
|
||
client, path, parent, namespace, owner, owner, perms_to_check
|
||
)
|
||
# if remaining directories don't exist, we have to create them
|
||
for new_dir in remaining_path_elts:
|
||
new_dir_id = shortuuid.uuid()
|
||
await self.storage.set_file(
|
||
client,
|
||
name=new_dir,
|
||
file_id=new_dir_id,
|
||
version="",
|
||
parent=parent,
|
||
type_=C.FILE_TYPE_DIRECTORY,
|
||
namespace=namespace,
|
||
created=created,
|
||
owner=owner,
|
||
access=access,
|
||
extra={},
|
||
)
|
||
parent = new_dir_id
|
||
finally:
|
||
self._file_path_lock.release()
|
||
|
||
elif parent is None:
|
||
parent = ""
|
||
|
||
await self.storage.set_file(
|
||
client,
|
||
file_id=file_id,
|
||
version=version,
|
||
parent=parent,
|
||
type_=type_,
|
||
file_hash=file_hash,
|
||
hash_algo=hash_algo,
|
||
name=name,
|
||
size=size,
|
||
namespace=namespace,
|
||
mime_type=mime_type,
|
||
public_id=public_id,
|
||
created=created,
|
||
modified=modified,
|
||
owner=owner,
|
||
access=access,
|
||
extra=extra,
|
||
)
|
||
|
||
async def file_get_used_space(
|
||
self, client, peer_jid: jid.JID, owner: Optional[jid.JID] = None
|
||
) -> int:
|
||
"""Get space taken by all files owned by an entity
|
||
|
||
@param peer_jid: entity requesting the size
|
||
@param owner: entity owning the file to check. If None, will be determined by
|
||
get_files_owner
|
||
@return: size of total space used by files of this owner
|
||
"""
|
||
owner = self.get_files_owner(client, owner, peer_jid)
|
||
if peer_jid.userhostJID() != owner and client.profile not in self.admins:
|
||
raise exceptions.PermissionError("You are not allowed to check this size")
|
||
return await self.storage.file_get_used_space(client, owner)
|
||
|
||
def file_update(
|
||
self, file_id: str, column: str, update_cb: Callable[[dict], None]
|
||
) -> Any:
|
||
"""Update a file column taking care of race condition
|
||
|
||
access is NOT checked in this method, it must be checked beforehand
|
||
@param file_id: id of the file to update
|
||
@param column: one of "access" or "extra"
|
||
@param update_cb: method to update the value of the colum
|
||
the method will take older value as argument, and must update it in place
|
||
Note that the callable must be thread-safe
|
||
"""
|
||
return self.storage.file_update(file_id, column, update_cb)
|
||
|
||
async def _delete_file(
|
||
self,
|
||
client: SatXMPPEntity,
|
||
peer_jid: jid.JID,
|
||
recursive: bool,
|
||
files_path: Path,
|
||
file_data: dict,
|
||
):
|
||
"""Internal method to delete files/directories recursively
|
||
|
||
@param peer_jid: entity requesting the deletion (must be owner of files
|
||
to delete)
|
||
@param recursive: True if recursive deletion is needed
|
||
@param files_path: path of the directory containing the actual files
|
||
@param file_data: data of the file to delete
|
||
"""
|
||
if file_data["owner"] != peer_jid:
|
||
raise exceptions.PermissionError(
|
||
"file {file_name} can't be deleted, {peer_jid} is not the owner".format(
|
||
file_name=file_data["name"], peer_jid=peer_jid.full()
|
||
)
|
||
)
|
||
if file_data["type"] == C.FILE_TYPE_DIRECTORY:
|
||
sub_files = await self.get_files(client, peer_jid, parent=file_data["id"])
|
||
if sub_files and not recursive:
|
||
raise exceptions.DataError(_("Can't delete directory, it is not empty"))
|
||
# we first delete the sub-files
|
||
for sub_file_data in sub_files:
|
||
if sub_file_data["type"] == C.FILE_TYPE_DIRECTORY:
|
||
sub_file_path = files_path / sub_file_data["name"]
|
||
else:
|
||
sub_file_path = files_path
|
||
await self._delete_file(
|
||
client, peer_jid, recursive, sub_file_path, sub_file_data
|
||
)
|
||
# then the directory itself
|
||
await self.storage.file_delete(file_data["id"])
|
||
elif file_data["type"] == C.FILE_TYPE_FILE:
|
||
log.info(
|
||
_("deleting file {name} with hash {file_hash}").format(
|
||
name=file_data["name"], file_hash=file_data["file_hash"]
|
||
)
|
||
)
|
||
await self.storage.file_delete(file_data["id"])
|
||
references = await self.get_files(
|
||
client, peer_jid, file_hash=file_data["file_hash"]
|
||
)
|
||
if references:
|
||
log.debug("there are still references to the file, we keep it")
|
||
else:
|
||
file_path = os.path.join(files_path, file_data["file_hash"])
|
||
log.info(
|
||
_("no reference left to {file_path}, deleting").format(
|
||
file_path=file_path
|
||
)
|
||
)
|
||
try:
|
||
os.unlink(file_path)
|
||
except FileNotFoundError:
|
||
log.error(
|
||
f"file at {file_path!r} doesn't exist but it was referenced in files database"
|
||
)
|
||
else:
|
||
raise exceptions.InternalError(
|
||
"Unexpected file type: {file_type}".format(file_type=file_data["type"])
|
||
)
|
||
|
||
async def file_delete(self, client, peer_jid, file_id, recursive=False):
|
||
"""Delete a single file or a directory and all its sub-files
|
||
|
||
@param file_id(unicode): id of the file to delete
|
||
@param peer_jid(jid.JID): entity requesting the deletion,
|
||
must be owner of all files to delete
|
||
@param recursive(boolean): must be True to delete a directory and all sub-files
|
||
"""
|
||
# FIXME: we only allow owner of file to delete files for now, but WRITE access
|
||
# should be checked too
|
||
files_data = await self.get_files(client, peer_jid, file_id)
|
||
if not files_data:
|
||
raise exceptions.NotFound(
|
||
"Can't find the file with id {file_id}".format(file_id=file_id)
|
||
)
|
||
file_data = files_data[0]
|
||
if file_data["type"] != C.FILE_TYPE_DIRECTORY and recursive:
|
||
raise ValueError("recursive can only be set for directories")
|
||
files_path = self.host.get_local_path(None, C.FILES_DIR)
|
||
await self._delete_file(client, peer_jid, recursive, files_path, file_data)
|
||
|
||
## Cache ##
|
||
|
||
def get_cache_path(self, namespace: str, *args: str) -> Path:
|
||
"""Get path to use to get a common path for a namespace
|
||
|
||
This can be used by plugins to manage permanent data. It's the responsability
|
||
of plugins to clean this directory from unused data.
|
||
@param namespace: unique namespace to use
|
||
@param args: extra identifier which will be added to the path
|
||
"""
|
||
namespace = namespace.strip().lower()
|
||
return Path(
|
||
self._cache_path,
|
||
regex.path_escape(namespace),
|
||
*(regex.path_escape(a) for a in args),
|
||
)
|
||
|
||
## Notifications ##
|
||
|
||
def _add_notification(
|
||
self,
|
||
type_: str,
|
||
body_plain: str,
|
||
body_rich: str,
|
||
title: str,
|
||
is_global: bool,
|
||
requires_action: bool,
|
||
priority: str,
|
||
expire_at: float,
|
||
extra_s: str,
|
||
profile_key: str,
|
||
) -> defer.Deferred:
|
||
client = self.host.get_client(profile_key)
|
||
|
||
if not client.is_admin:
|
||
raise exceptions.PermissionError("Only admins can add a notification")
|
||
|
||
try:
|
||
notification_type = NotificationType[type_]
|
||
notification_priority = NotificationPriority[priority]
|
||
except KeyError as e:
|
||
raise exceptions.DataError(f"invalid notification type or priority data: {e}")
|
||
|
||
return defer.ensureDeferred(
|
||
self.add_notification(
|
||
client,
|
||
notification_type,
|
||
body_plain,
|
||
body_rich or None,
|
||
title or None,
|
||
is_global,
|
||
requires_action,
|
||
notification_priority,
|
||
expire_at or None,
|
||
data_format.deserialise(extra_s),
|
||
)
|
||
)
|
||
|
||
async def add_notification(
|
||
self,
|
||
client: SatXMPPEntity,
|
||
type_: NotificationType,
|
||
body_plain: str,
|
||
body_rich: Optional[str] = None,
|
||
title: Optional[str] = None,
|
||
is_global: bool = False,
|
||
requires_action: bool = False,
|
||
priority: NotificationPriority = NotificationPriority.MEDIUM,
|
||
expire_at: Optional[float] = None,
|
||
extra: Optional[dict] = None,
|
||
) -> None:
|
||
"""Create and broadcast a new notification.
|
||
|
||
@param client: client associated with the notification. If None, the notification
|
||
will be global (i.e. for all profiles).
|
||
@param type_: type of the notification.
|
||
@param body_plain: plain text body.
|
||
@param body_rich: rich text (XHTML) body.
|
||
@param title: optional title.
|
||
@param is_global: True if the notification is for all profiles.
|
||
@param requires_action: True if the notification requires user action (e.g. a
|
||
dialog need to be answered).
|
||
@priority: how urgent the notification is
|
||
@param expire_at: expiration timestamp for the notification.
|
||
@param extra: additional data.
|
||
"""
|
||
notification = await self.storage.add_notification(
|
||
None if is_global else client,
|
||
type_,
|
||
body_plain,
|
||
body_rich,
|
||
title,
|
||
requires_action,
|
||
priority,
|
||
expire_at,
|
||
extra,
|
||
)
|
||
self.host.bridge.notification_new(
|
||
str(notification.id),
|
||
notification.timestamp,
|
||
type_.value,
|
||
body_plain,
|
||
body_rich or "",
|
||
title or "",
|
||
requires_action,
|
||
priority.value,
|
||
expire_at or 0,
|
||
data_format.serialise(extra) if extra else "",
|
||
C.PROF_KEY_ALL if is_global else client.profile,
|
||
)
|
||
log.debug(
|
||
f"Notification sent:\n"
|
||
f" title: {title!r}\n"
|
||
f" body_plain: {body_plain!r}\n"
|
||
f" type: {type_.value}\n"
|
||
f" priority: {priority.value}\n"
|
||
f" profile: {C.PROF_KEY_ALL if is_global else client.profile}"
|
||
)
|
||
|
||
def _get_notifications(self, filters_s: str, profile_key: str) -> defer.Deferred:
|
||
"""Fetch notifications for bridge with given filters and profile key.
|
||
|
||
@param filters_s: serialized filter conditions. Keys can be:
|
||
:type_ (str):
|
||
Filter by type of the notification.
|
||
:status (str):
|
||
Filter by status of the notification.
|
||
:requires_action (bool):
|
||
Filter by notifications that require user action.
|
||
:min_priority (str):
|
||
Filter by minimum priority value.
|
||
@param profile_key: key of the profile to fetch notifications for.
|
||
@return: Deferred which fires with a list of serialised notifications.
|
||
"""
|
||
client = self.host.get_client(profile_key)
|
||
|
||
filters = data_format.deserialise(filters_s)
|
||
|
||
try:
|
||
if "type" in filters:
|
||
filters["type_"] = NotificationType[filters.pop("type")]
|
||
if "status" in filters:
|
||
filters["status"] = NotificationStatus[filters["status"]]
|
||
if "min_priority" in filters:
|
||
filters["min_priority"] = NotificationPriority[
|
||
filters["min_priority"]
|
||
].value
|
||
except KeyError as e:
|
||
raise exceptions.DataError(f"invalid filter data: {e}")
|
||
|
||
d = defer.ensureDeferred(self.storage.get_notifications(client, **filters))
|
||
d.addCallback(
|
||
lambda notifications: data_format.serialise(
|
||
[notification.serialise() for notification in notifications]
|
||
)
|
||
)
|
||
return d
|
||
|
||
def _delete_notification(
|
||
self, id_: str, is_global: bool, profile_key: str
|
||
) -> defer.Deferred:
|
||
client = self.host.get_client(profile_key)
|
||
if is_global and not client.is_admin:
|
||
raise exceptions.PermissionError(
|
||
"Only admins can delete global notifications"
|
||
)
|
||
return defer.ensureDeferred(self.delete_notification(client, id_, is_global))
|
||
|
||
async def delete_notification(
|
||
self, client: SatXMPPEntity, id_: str, is_global: bool = False
|
||
) -> None:
|
||
"""Delete a notification
|
||
|
||
the notification must be from the requesting profile.
|
||
@param id_: ID of the notification
|
||
is_global: if True, a global notification will be removed.
|
||
"""
|
||
await self.storage.delete_notification(None if is_global else client, id_)
|
||
self.host.bridge.notification_deleted(
|
||
id_, C.PROF_KEY_ALL if is_global else client.profile
|
||
)
|
||
|
||
def _notifications_expired_clean(
|
||
self, limit_timestamp: float, profile_key: str
|
||
) -> defer.Deferred:
|
||
if profile_key == C.PROF_KEY_NONE:
|
||
client = None
|
||
else:
|
||
client = self.host.get_client(profile_key)
|
||
|
||
return defer.ensureDeferred(
|
||
self.storage.clean_expired_notifications(
|
||
client, None if limit_timestamp == -1.0 else limit_timestamp
|
||
)
|
||
)
|
||
|
||
## Misc ##
|
||
|
||
def is_entity_available(self, client, entity_jid):
|
||
"""Tell from the presence information if the given entity is available.
|
||
|
||
@param entity_jid (JID): the entity to check (if bare jid is used, all resources are tested)
|
||
@return (bool): True if entity is available
|
||
"""
|
||
if not entity_jid.resource:
|
||
return bool(
|
||
self.get_available_resources(client, entity_jid)
|
||
) # is any resource is available, entity is available
|
||
try:
|
||
presence_data = self.get_entity_datum(client, entity_jid, "presence")
|
||
except KeyError:
|
||
log.debug("No presence information for {}".format(entity_jid))
|
||
return False
|
||
return presence_data.show != C.PRESENCE_UNAVAILABLE
|
||
|
||
def is_admin(self, profile: str) -> bool:
|
||
"""Tell if given profile has administrator privileges"""
|
||
return profile in self.admins
|
||
|
||
def is_admin_jid(self, entity: jid.JID) -> bool:
|
||
"""Tells if an entity jid correspond to an admin one
|
||
|
||
It is sometime not possible to use the profile alone to check if an entity is an
|
||
admin (e.g. a request managed by a component). In this case we check if the JID
|
||
correspond to an admin profile
|
||
"""
|
||
return entity.userhostJID() in self.admin_jids
|