v1.3.5
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
########################################################################
|
||||
# File name: __init__.py
|
||||
# This file is part of: aioxmpp
|
||||
#
|
||||
# LICENSE
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser 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
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
########################################################################
|
||||
"""
|
||||
:mod:`~aioxmpp.entitycaps` --- Entity Capabilities support (:xep:`390`, :xep:`0115`)
|
||||
####################################################################################
|
||||
|
||||
This module provides support for :xep:`XEP-0115 (Entity Capabilities) <0115>`
|
||||
and :xep:`XEP-0390 (Entity Capabilities 2.0) <0390>`. To use it,
|
||||
:meth:`.Client.summon` the :class:`aioxmpp.EntityCapsService` on a
|
||||
:class:`~.Client`. See the service documentation for more information.
|
||||
|
||||
.. versionadded:: 0.5
|
||||
|
||||
.. versionchanged:: 0.9
|
||||
|
||||
Support for :xep:`390` was added.
|
||||
|
||||
Service
|
||||
=======
|
||||
|
||||
.. currentmodule:: aioxmpp
|
||||
|
||||
.. autoclass:: EntityCapsService
|
||||
|
||||
.. currentmodule:: aioxmpp.entitycaps
|
||||
|
||||
.. class:: Service
|
||||
|
||||
Alias of :class:`.EntityCapsService`.
|
||||
|
||||
.. deprecated:: 0.8
|
||||
|
||||
The alias will be removed in 1.0.
|
||||
|
||||
.. autoclass:: Cache
|
||||
|
||||
.. currentmodule:: aioxmpp.entitycaps.xso
|
||||
|
||||
|
||||
""" # NOQA: E501
|
||||
|
||||
from .service import EntityCapsService, Cache # NOQA: F401
|
||||
from . import xso # NOQA: F401
|
||||
Service = EntityCapsService
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -0,0 +1,175 @@
|
||||
########################################################################
|
||||
# File name: caps115.py
|
||||
# This file is part of: aioxmpp
|
||||
#
|
||||
# LICENSE
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser 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
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
########################################################################
|
||||
import base64
|
||||
import collections
|
||||
import hashlib
|
||||
import pathlib
|
||||
import urllib.parse
|
||||
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
from .common import AbstractKey, AbstractImplementation
|
||||
from . import xso as caps_xso
|
||||
|
||||
|
||||
def build_identities_string(identities):
|
||||
identities = [
|
||||
b"/".join([
|
||||
escape(identity.category).encode("utf-8"),
|
||||
escape(identity.type_).encode("utf-8"),
|
||||
escape(str(identity.lang or "")).encode("utf-8"),
|
||||
escape(identity.name or "").encode("utf-8"),
|
||||
])
|
||||
for identity in identities
|
||||
]
|
||||
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("duplicate identity")
|
||||
|
||||
identities.sort()
|
||||
identities.append(b"")
|
||||
return b"<".join(identities)
|
||||
|
||||
|
||||
def build_features_string(features):
|
||||
features = list(escape(feature).encode("utf-8") for feature in features)
|
||||
|
||||
if len(set(features)) != len(features):
|
||||
raise ValueError("duplicate feature")
|
||||
|
||||
features.sort()
|
||||
features.append(b"")
|
||||
return b"<".join(features)
|
||||
|
||||
|
||||
def build_forms_string(forms):
|
||||
types = set()
|
||||
forms_list = []
|
||||
for form in forms:
|
||||
try:
|
||||
form_types = set(
|
||||
value
|
||||
for field in form.fields.filter(attrs={"var": "FORM_TYPE"})
|
||||
for value in field.values
|
||||
)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
if len(form_types) > 1:
|
||||
raise ValueError("form with multiple types")
|
||||
elif not form_types:
|
||||
continue
|
||||
|
||||
type_ = escape(next(iter(form_types))).encode("utf-8")
|
||||
if type_ in types:
|
||||
raise ValueError("multiple forms of type {!r}".format(type_))
|
||||
types.add(type_)
|
||||
forms_list.append((type_, form))
|
||||
forms_list.sort()
|
||||
|
||||
parts = []
|
||||
|
||||
for type_, form in forms_list:
|
||||
parts.append(type_)
|
||||
|
||||
field_list = sorted(
|
||||
(
|
||||
(escape(field.var).encode("utf-8"), field.values)
|
||||
for field in form.fields
|
||||
if field.var != "FORM_TYPE"
|
||||
),
|
||||
key=lambda x: x[0]
|
||||
)
|
||||
|
||||
for var, values in field_list:
|
||||
parts.append(var)
|
||||
parts.extend(sorted(
|
||||
escape(value).encode("utf-8") for value in values
|
||||
))
|
||||
|
||||
parts.append(b"")
|
||||
return b"<".join(parts)
|
||||
|
||||
|
||||
def hash_query(query, algo):
|
||||
hashimpl = hashlib.new(algo)
|
||||
hashimpl.update(
|
||||
build_identities_string(query.identities)
|
||||
)
|
||||
hashimpl.update(
|
||||
build_features_string(query.features)
|
||||
)
|
||||
hashimpl.update(
|
||||
build_forms_string(query.exts)
|
||||
)
|
||||
|
||||
return base64.b64encode(hashimpl.digest()).decode("ascii")
|
||||
|
||||
|
||||
Key = collections.namedtuple("Key", ["algo", "node"])
|
||||
|
||||
|
||||
class Key(Key, AbstractKey):
|
||||
@property
|
||||
def path(self):
|
||||
quoted = urllib.parse.quote(self.node, safe="")
|
||||
return (pathlib.Path("hashes") /
|
||||
"{}_{}.xml".format(self.algo, quoted))
|
||||
|
||||
@property
|
||||
def ver(self):
|
||||
return self.node.rsplit("#", 1)[1]
|
||||
|
||||
def verify(self, query_response):
|
||||
digest_b64 = hash_query(query_response, self.algo.replace("-", ""))
|
||||
return self.ver == digest_b64
|
||||
|
||||
|
||||
class Implementation(AbstractImplementation):
|
||||
def __init__(self, node, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.__node = node
|
||||
|
||||
def extract_keys(self, obj):
|
||||
caps = obj.xep0115_caps
|
||||
if caps is None or caps.hash_ is None:
|
||||
return
|
||||
|
||||
yield Key(caps.hash_, "{}#{}".format(caps.node, caps.ver))
|
||||
|
||||
def put_keys(self, keys, presence):
|
||||
key, = keys
|
||||
|
||||
presence.xep0115_caps = caps_xso.Caps115(
|
||||
self.__node,
|
||||
key.ver,
|
||||
key.algo,
|
||||
)
|
||||
|
||||
def calculate_keys(self, query_response):
|
||||
yield Key(
|
||||
"sha-1",
|
||||
"{}#{}".format(
|
||||
self.__node,
|
||||
hash_query(query_response, "sha1"),
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,192 @@
|
||||
########################################################################
|
||||
# File name: caps390.py
|
||||
# This file is part of: aioxmpp
|
||||
#
|
||||
# LICENSE
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser 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
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
########################################################################
|
||||
import base64
|
||||
import pathlib
|
||||
import collections
|
||||
import urllib.parse
|
||||
|
||||
import aioxmpp.hashes
|
||||
|
||||
from .common import AbstractKey
|
||||
from . import xso as caps_xso
|
||||
|
||||
|
||||
def _process_features(features):
|
||||
"""
|
||||
Generate the `Features String` from an iterable of features.
|
||||
|
||||
:param features: The features to generate the features string from.
|
||||
:type features: :class:`~collections.abc.Iterable` of :class:`str`
|
||||
:return: The `Features String`
|
||||
:rtype: :class:`bytes`
|
||||
|
||||
Generate the `Features String` from the given `features` as specified in
|
||||
:xep:`390`.
|
||||
"""
|
||||
parts = [
|
||||
feature.encode("utf-8")+b"\x1f"
|
||||
for feature in features
|
||||
]
|
||||
parts.sort()
|
||||
return b"".join(parts)+b"\x1c"
|
||||
|
||||
|
||||
def _process_identity(identity):
|
||||
category = (identity.category or "").encode("utf-8")+b"\x1f"
|
||||
type_ = (identity.type_ or "").encode("utf-8")+b"\x1f"
|
||||
lang = str(identity.lang or "").encode("utf-8")+b"\x1f"
|
||||
name = (identity.name or "").encode("utf-8")+b"\x1f"
|
||||
|
||||
return b"".join([category, type_, lang, name]) + b"\x1e"
|
||||
|
||||
|
||||
def _process_identities(identities):
|
||||
"""
|
||||
Generate the `Identities String` from an iterable of identities.
|
||||
|
||||
:param identities: The identities to generate the features string from.
|
||||
:type identities: :class:`~collections.abc.Iterable` of
|
||||
:class:`~.disco.xso.Identity`
|
||||
:return: The `Identities String`
|
||||
:rtype: :class:`bytes`
|
||||
|
||||
Generate the `Identities String` from the given `identities` as specified
|
||||
in :xep:`390`.
|
||||
"""
|
||||
parts = [
|
||||
_process_identity(identity)
|
||||
for identity in identities
|
||||
]
|
||||
parts.sort()
|
||||
return b"".join(parts)+b"\x1c"
|
||||
|
||||
|
||||
def _process_field(field):
|
||||
parts = [
|
||||
(value or "").encode("utf-8") + b"\x1f"
|
||||
for value in field.values
|
||||
]
|
||||
|
||||
parts.insert(0, field.var.encode("utf-8")+b"\x1f")
|
||||
return b"".join(parts)+b"\x1e"
|
||||
|
||||
|
||||
def _process_form(form):
|
||||
parts = [
|
||||
_process_field(form)
|
||||
for form in form.fields
|
||||
]
|
||||
|
||||
parts.sort()
|
||||
return b"".join(parts)+b"\x1d"
|
||||
|
||||
|
||||
def _process_extensions(exts):
|
||||
"""
|
||||
Generate the `Extensions String` from an iterable of data forms.
|
||||
|
||||
:param exts: The data forms to generate the extensions string from.
|
||||
:type exts: :class:`~collections.abc.Iterable` of
|
||||
:class:`~.forms.xso.Data`
|
||||
:return: The `Extensions String`
|
||||
:rtype: :class:`bytes`
|
||||
|
||||
Generate the `Extensions String` from the given `exts` as specified
|
||||
in :xep:`390`.
|
||||
"""
|
||||
parts = [
|
||||
_process_form(form)
|
||||
for form in exts
|
||||
]
|
||||
parts.sort()
|
||||
return b"".join(parts)+b"\x1c"
|
||||
|
||||
|
||||
def _get_hash_input(info):
|
||||
return b"".join([
|
||||
_process_features(info.features),
|
||||
_process_identities(info.identities),
|
||||
_process_extensions(info.exts)
|
||||
])
|
||||
|
||||
|
||||
def _calculate_hash(algo, hash_input):
|
||||
impl = aioxmpp.hashes.hash_from_algo(algo)
|
||||
impl.update(hash_input)
|
||||
return impl.digest()
|
||||
|
||||
|
||||
Key = collections.namedtuple("Key", ["algo", "digest"])
|
||||
|
||||
|
||||
class Key(Key, AbstractKey):
|
||||
@property
|
||||
def node(self):
|
||||
return "urn:xmpp:caps#{}.{}".format(
|
||||
self.algo,
|
||||
base64.b64encode(self.digest).decode("ascii")
|
||||
)
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
encoded = base64.b32encode(
|
||||
self.digest
|
||||
).decode("ascii").rstrip("=").lower()
|
||||
return (pathlib.Path("caps2") /
|
||||
urllib.parse.quote(self.algo, safe="") /
|
||||
encoded[:2] /
|
||||
encoded[2:4] /
|
||||
"{}.xml".format(encoded[4:]))
|
||||
|
||||
def verify(self, info):
|
||||
if not isinstance(info, bytes):
|
||||
info = _get_hash_input(info)
|
||||
digest = _calculate_hash(self.algo, info)
|
||||
return digest == self.digest
|
||||
|
||||
|
||||
class Implementation:
|
||||
def __init__(self, algorithms, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.__algorithms = algorithms
|
||||
|
||||
def extract_keys(self, presence):
|
||||
if presence.xep0390_caps is None:
|
||||
return ()
|
||||
|
||||
return (
|
||||
Key(algo, digest)
|
||||
for algo, digest in presence.xep0390_caps.digests.items()
|
||||
if aioxmpp.hashes.is_algo_supported(algo)
|
||||
)
|
||||
|
||||
def put_keys(self, keys, presence):
|
||||
presence.xep0390_caps = caps_xso.Caps390()
|
||||
presence.xep0390_caps.digests.update({
|
||||
key.algo: key.digest
|
||||
for key in keys
|
||||
})
|
||||
|
||||
def calculate_keys(self, query_response):
|
||||
input = _get_hash_input(query_response)
|
||||
for algo in self.__algorithms:
|
||||
yield Key(algo, _calculate_hash(algo, input))
|
||||
@@ -0,0 +1,105 @@
|
||||
########################################################################
|
||||
# File name: common.py
|
||||
# This file is part of: aioxmpp
|
||||
#
|
||||
# LICENSE
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser 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
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
########################################################################
|
||||
import abc
|
||||
|
||||
|
||||
class AbstractKey(metaclass=abc.ABCMeta):
|
||||
@abc.abstractproperty
|
||||
def path(self):
|
||||
"""
|
||||
Return the file system path relative to the root of a file-system based
|
||||
caps database for this key.
|
||||
|
||||
The path includes all information of the key. Components of the path do
|
||||
not exceed 255 codepoints and use only ASCII codepoints.
|
||||
|
||||
If it is not possible to create such a path, :class:`ValueError` is
|
||||
raised.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def verify(self, query_response):
|
||||
"""
|
||||
Verify whether the cache key matches a piece of service discovery
|
||||
information.
|
||||
|
||||
:param query_response: The full :xep:`30` disco#info query response.
|
||||
:type query_response: :class:`~.disco.xso.InfoQuery`
|
||||
:rtype: :class:`bool`
|
||||
:return: true if the key matches and false otherwise.
|
||||
"""
|
||||
|
||||
|
||||
class AbstractImplementation(metaclass=abc.ABCMeta):
|
||||
@abc.abstractmethod
|
||||
def extract_keys(self, presence):
|
||||
"""
|
||||
Extract cache keys from a presence stanza.
|
||||
|
||||
:param presence: Presence stanza to extract cache keys from.
|
||||
:type presence: :class:`aioxmpp.Presence`
|
||||
:rtype: :class:`~collections.abc.Iterable` of :class:`AbstractKey`
|
||||
:return: The cache keys from the presence stanza.
|
||||
|
||||
The resulting iterable may be empty if the presence stanza does not
|
||||
carry any capabilities information with it.
|
||||
|
||||
The resulting iterable cannot be iterated over multiple times.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def put_keys(self, keys, presence):
|
||||
"""
|
||||
Insert cache keys into a presence stanza.
|
||||
|
||||
:param keys: An iterable of cache keys to insert.
|
||||
:type keys: :class:`~collections.abc.Iterable` of :class:`AbstractKey`
|
||||
objects
|
||||
:param presence: The presence stanza into which the cache keys shall be
|
||||
injected.
|
||||
:type presence: :class:`aioxmpp.Presence`
|
||||
|
||||
The presence stanza is modified in-place.
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def calculate_keys(self, query_response):
|
||||
"""
|
||||
Calculate the cache keys for a disco#info response.
|
||||
|
||||
:param query_response: The full :xep:`30` disco#info query response.
|
||||
:type query_response: :class:`~.disco.xso.InfoQuery`
|
||||
:rtype: :class:`~collections.abc.Iterable` of :class:`AbstractKey`
|
||||
:return: An iterable of the cache keys for the disco#info response.
|
||||
|
||||
..
|
||||
|
||||
:param identities: The identities of the disco#info response.
|
||||
:type identities: :class:`~collections.abc.Iterable` of
|
||||
:class:`~.disco.xso.Identity`
|
||||
:param features: The features of the disco#info response.
|
||||
:type features: :class:`~collections.abc.Iterable` of
|
||||
:class:`str`
|
||||
:param features: The extensions of the disco#info response.
|
||||
:type features: :class:`~collections.abc.Iterable` of
|
||||
:class:`~.forms.xso.Data`
|
||||
"""
|
||||
@@ -0,0 +1,513 @@
|
||||
########################################################################
|
||||
# File name: service.py
|
||||
# This file is part of: aioxmpp
|
||||
#
|
||||
# LICENSE
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser 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
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
########################################################################
|
||||
import asyncio
|
||||
import collections
|
||||
import copy
|
||||
import functools
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
import aioxmpp.callbacks
|
||||
import aioxmpp.disco as disco
|
||||
import aioxmpp.service
|
||||
import aioxmpp.utils
|
||||
import aioxmpp.xml
|
||||
import aioxmpp.xso
|
||||
|
||||
from aioxmpp.utils import namespaces
|
||||
|
||||
from . import caps115, caps390
|
||||
|
||||
|
||||
logger = logging.getLogger("aioxmpp.entitycaps")
|
||||
|
||||
|
||||
class Cache:
|
||||
"""
|
||||
This provides a two-level cache for entity capabilities information. The
|
||||
idea is to have a trusted database, e.g. installed system-wide or shipped
|
||||
with :mod:`aioxmpp` and in addition a user-level database which is
|
||||
automatically filled with hashes which have been found by the
|
||||
:class:`Service`.
|
||||
|
||||
The trusted database is taken as read-only and overrides the user-collected
|
||||
database. When a hash is in both databases, it is removed from the
|
||||
user-collected database (to save space).
|
||||
|
||||
In addition to serving the databases, it provides deduplication for queries
|
||||
by holding a cache of futures looking up the same hash.
|
||||
|
||||
Database management (user API):
|
||||
|
||||
.. automethod:: set_system_db_path
|
||||
|
||||
.. automethod:: set_user_db_path
|
||||
|
||||
Queries (API intended for :class:`Service`):
|
||||
|
||||
.. automethod:: create_query_future
|
||||
|
||||
.. automethod:: lookup_in_database
|
||||
|
||||
.. automethod:: lookup
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self._lookup_cache = {}
|
||||
self._memory_overlay = {}
|
||||
self._system_db_path = None
|
||||
self._user_db_path = None
|
||||
|
||||
def _erase_future(self, key, fut):
|
||||
try:
|
||||
existing = self._lookup_cache[key]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
if existing is fut:
|
||||
del self._lookup_cache[key]
|
||||
|
||||
def set_system_db_path(self, path):
|
||||
self._system_db_path = path
|
||||
|
||||
def set_user_db_path(self, path):
|
||||
self._user_db_path = path
|
||||
|
||||
def lookup_in_database(self, key):
|
||||
try:
|
||||
result = self._memory_overlay[key]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
logger.debug("memory cache hit: %s", key)
|
||||
return result
|
||||
|
||||
key_path = key.path
|
||||
|
||||
if self._system_db_path is not None:
|
||||
try:
|
||||
f = (
|
||||
self._system_db_path / key_path
|
||||
).open("rb")
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
logger.debug("system db hit: %s", key)
|
||||
with f:
|
||||
return aioxmpp.xml.read_single_xso(f, disco.xso.InfoQuery)
|
||||
|
||||
if self._user_db_path is not None:
|
||||
try:
|
||||
f = (
|
||||
self._user_db_path / key_path
|
||||
).open("rb")
|
||||
except OSError:
|
||||
pass
|
||||
else:
|
||||
logger.debug("user db hit: %s", key)
|
||||
with f:
|
||||
return aioxmpp.xml.read_single_xso(f, disco.xso.InfoQuery)
|
||||
|
||||
raise KeyError(key)
|
||||
|
||||
async def lookup(self, key):
|
||||
"""
|
||||
Look up the given `node` URL using the given `hash_` first in the
|
||||
database and then by waiting on the futures created with
|
||||
:meth:`create_query_future` for that node URL and hash.
|
||||
|
||||
If the hash is not in the database, :meth:`lookup` iterates as long as
|
||||
there are pending futures for the given `hash_` and `node`. If there
|
||||
are no pending futures, :class:`KeyError` is raised. If a future raises
|
||||
a :class:`ValueError`, it is ignored. If the future returns a value, it
|
||||
is used as the result.
|
||||
"""
|
||||
try:
|
||||
result = self.lookup_in_database(key)
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
return result
|
||||
|
||||
while True:
|
||||
fut = self._lookup_cache[key]
|
||||
try:
|
||||
result = await fut
|
||||
except ValueError:
|
||||
continue
|
||||
else:
|
||||
return result
|
||||
|
||||
def create_query_future(self, key):
|
||||
"""
|
||||
Create and return a :class:`asyncio.Future` for the given `hash_`
|
||||
function and `node` URL. The future is referenced internally and used
|
||||
by any calls to :meth:`lookup` which are made while the future is
|
||||
pending. The future is removed from the internal storage automatically
|
||||
when a result or exception is set for it.
|
||||
|
||||
This allows for deduplication of queries for the same hash.
|
||||
"""
|
||||
fut = asyncio.Future()
|
||||
fut.add_done_callback(
|
||||
functools.partial(self._erase_future, key)
|
||||
)
|
||||
self._lookup_cache[key] = fut
|
||||
return fut
|
||||
|
||||
def add_cache_entry(self, key, entry):
|
||||
"""
|
||||
Add the given `entry` (which must be a :class:`~.disco.xso.InfoQuery`
|
||||
instance) to the user-level database keyed with the hash function type
|
||||
`hash_` and the `node` URL. The `entry` is **not** validated to
|
||||
actually map to `node` with the given `hash_` function, it is expected
|
||||
that the caller performs the validation.
|
||||
"""
|
||||
copied_entry = copy.copy(entry)
|
||||
self._memory_overlay[key] = copied_entry
|
||||
if self._user_db_path is not None:
|
||||
asyncio.ensure_future(asyncio.get_event_loop().run_in_executor(
|
||||
None,
|
||||
writeback,
|
||||
self._user_db_path / key.path,
|
||||
entry.captured_events))
|
||||
|
||||
|
||||
class EntityCapsService(aioxmpp.service.Service):
|
||||
"""
|
||||
Make use and provide service discovery information in presence broadcasts.
|
||||
|
||||
This service implements :xep:`0115` and :xep:`0390`, transparently.
|
||||
Besides loading the service, no interaction is required to get some of
|
||||
the benefits of :xep:`0115` and :xep:`0390`.
|
||||
|
||||
Two additional things need to be done by users to get full support and
|
||||
performance:
|
||||
|
||||
1. To make sure that peers are always up-to-date with the current
|
||||
capabilities, it is required that users listen on the
|
||||
:meth:`on_ver_changed` signal and re-emit their current presence when it
|
||||
fires.
|
||||
|
||||
.. note::
|
||||
|
||||
Keeping peers up-to-date is a MUST in :xep:`390`.
|
||||
|
||||
The service takes care of attaching capabilities information on the
|
||||
outgoing stanza, using a stanza filter.
|
||||
|
||||
.. warning::
|
||||
|
||||
:meth:`on_ver_changed` may be emitted at a considerable rate when
|
||||
services are loaded or certain features (such as PEP-based services)
|
||||
are configured. It is up to the application to limit the rate at
|
||||
which presences are sent for the sole purpose of updating peers with
|
||||
new capability information.
|
||||
|
||||
2. Users should use a process-wide :class:`Cache` instance and assign it to
|
||||
the :attr:`cache` of each :class:`.entitycaps.Service` they use. This
|
||||
improves performance by sharing (verified) hashes among :class:`Service`
|
||||
instances.
|
||||
|
||||
In addition, the hashes should be saved and restored on shutdown/start
|
||||
of the process. See the :class:`Cache` for details.
|
||||
|
||||
.. signal:: on_ver_changed
|
||||
|
||||
The signal emits whenever the Capability Hashset of the local client
|
||||
changes. This happens when the set of features or identities announced
|
||||
in the :class:`.DiscoServer` changes.
|
||||
|
||||
.. autoattribute:: cache
|
||||
|
||||
.. autoattribute:: xep115_support
|
||||
|
||||
.. autoattribute:: xep390_support
|
||||
|
||||
.. versionchanged:: 0.8
|
||||
|
||||
This class was formerly known as :class:`aioxmpp.entitycaps.Service`. It
|
||||
is still available under that name, but the alias will be removed in
|
||||
1.0.
|
||||
|
||||
.. versionchanged:: 0.9
|
||||
|
||||
Support for :xep:`390` was added.
|
||||
|
||||
"""
|
||||
|
||||
ORDER_AFTER = {
|
||||
disco.DiscoClient,
|
||||
disco.DiscoServer,
|
||||
}
|
||||
|
||||
NODE = "http://aioxmpp.zombofant.net/"
|
||||
|
||||
on_ver_changed = aioxmpp.callbacks.Signal()
|
||||
|
||||
def __init__(self, node, **kwargs):
|
||||
super().__init__(node, **kwargs)
|
||||
|
||||
self.__current_keys = {}
|
||||
self._cache = Cache()
|
||||
|
||||
self.disco_server = self.dependencies[disco.DiscoServer]
|
||||
self.disco_client = self.dependencies[disco.DiscoClient]
|
||||
|
||||
self.__115 = caps115.Implementation(self.NODE)
|
||||
self.__390 = caps390.Implementation(
|
||||
aioxmpp.hashes.default_hash_algorithms
|
||||
)
|
||||
|
||||
self.__active_hashsets = []
|
||||
self.__key_users = collections.Counter()
|
||||
|
||||
@property
|
||||
def xep115_support(self):
|
||||
"""
|
||||
Boolean to control whether :xep:`115` support is enabled or not.
|
||||
|
||||
Defaults to :data:`True`.
|
||||
|
||||
If set to false, inbound :xep:`115` capabilities will not be processed
|
||||
and no :xep:`115` capabilities will be emitted.
|
||||
|
||||
.. note::
|
||||
|
||||
At some point, this will default to :data:`False` to save
|
||||
bandwidth. The exact release depends on the adoption of :xep:`390`
|
||||
and will be announced in time. If you depend on :xep:`115` support,
|
||||
set this boolean to :data:`True`.
|
||||
|
||||
The attribute itself will not be removed until :xep:`115` support
|
||||
is removed from :mod:`aioxmpp` entirely, which is unlikely to
|
||||
happen any time soon.
|
||||
|
||||
.. versionadded:: 0.9
|
||||
"""
|
||||
|
||||
return self._xep115_feature.enabled
|
||||
|
||||
@xep115_support.setter
|
||||
def xep115_support(self, value):
|
||||
self._xep115_feature.enabled = value
|
||||
|
||||
@property
|
||||
def xep390_support(self):
|
||||
"""
|
||||
Boolean to control whether :xep:`390` support is enabled or not.
|
||||
|
||||
Defaults to :data:`True`.
|
||||
|
||||
If set to false, inbound :xep:`390` Capability Hash Sets will not be
|
||||
processed and no Capability Hash Sets or Capability Nodes will be
|
||||
generated.
|
||||
|
||||
The hash algorithms used for generating Capability Hash Sets are those
|
||||
from :data:`aioxmpp.hashes.default_hash_algorithms`.
|
||||
"""
|
||||
return self._xep390_feature.enabled
|
||||
|
||||
@xep390_support.setter
|
||||
def xep390_support(self, value):
|
||||
self._xep390_feature.enabled = value
|
||||
|
||||
@property
|
||||
def cache(self):
|
||||
"""
|
||||
The :class:`Cache` instance used for this :class:`Service`. Deleting
|
||||
this attribute will automatically create a new :class:`Cache` instance.
|
||||
|
||||
The attribute can be used to share a single :class:`Cache` among
|
||||
multiple :class:`Service` instances.
|
||||
"""
|
||||
return self._cache
|
||||
|
||||
@cache.setter
|
||||
def cache(self, v):
|
||||
self._cache = v
|
||||
|
||||
@cache.deleter
|
||||
def cache(self):
|
||||
self._cache = Cache()
|
||||
|
||||
@aioxmpp.service.depsignal(
|
||||
disco.DiscoServer,
|
||||
"on_info_changed")
|
||||
def _info_changed(self):
|
||||
self.logger.debug("info changed, scheduling re-calculation of version")
|
||||
asyncio.get_event_loop().call_soon(
|
||||
self.update_hash
|
||||
)
|
||||
|
||||
async def _shutdown(self):
|
||||
for group in self.__current_keys.values():
|
||||
for key in group:
|
||||
self.disco_server.unmount_node(key.node)
|
||||
|
||||
async def query_and_cache(self, jid, key, fut):
|
||||
data = await self.disco_client.query_info(
|
||||
jid,
|
||||
node=key.node,
|
||||
require_fresh=True,
|
||||
no_cache=True, # the caps node is never queried by apps
|
||||
)
|
||||
|
||||
try:
|
||||
if key.verify(data):
|
||||
self.cache.add_cache_entry(key, data)
|
||||
fut.set_result(data)
|
||||
else:
|
||||
raise ValueError("hash mismatch")
|
||||
except ValueError as exc:
|
||||
fut.set_exception(exc)
|
||||
|
||||
return data
|
||||
|
||||
async def lookup_info(self, jid, keys):
|
||||
for key in keys:
|
||||
try:
|
||||
info = await self.cache.lookup(key)
|
||||
except KeyError:
|
||||
continue
|
||||
|
||||
self.logger.debug("found %s in cache", key)
|
||||
return info
|
||||
|
||||
first_key = keys[0]
|
||||
self.logger.debug("using key %s to query peer", first_key)
|
||||
fut = self.cache.create_query_future(first_key)
|
||||
info = await self.query_and_cache(
|
||||
jid, first_key, fut
|
||||
)
|
||||
self.logger.debug("%s maps to %r", key, info)
|
||||
|
||||
return info
|
||||
|
||||
@aioxmpp.service.outbound_presence_filter
|
||||
def handle_outbound_presence(self, presence):
|
||||
if (presence.type_ == aioxmpp.structs.PresenceType.AVAILABLE
|
||||
and self.__active_hashsets):
|
||||
current_hashset = self.__active_hashsets[-1]
|
||||
|
||||
try:
|
||||
keys = current_hashset[self.__115]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
self.__115.put_keys(keys, presence)
|
||||
|
||||
try:
|
||||
keys = current_hashset[self.__390]
|
||||
except KeyError:
|
||||
pass
|
||||
else:
|
||||
self.__390.put_keys(keys, presence)
|
||||
|
||||
return presence
|
||||
|
||||
@aioxmpp.service.inbound_presence_filter
|
||||
def handle_inbound_presence(self, presence):
|
||||
keys = []
|
||||
|
||||
if self.xep390_support:
|
||||
keys.extend(self.__390.extract_keys(presence))
|
||||
|
||||
if self.xep115_support:
|
||||
keys.extend(self.__115.extract_keys(presence))
|
||||
|
||||
if keys:
|
||||
lookup_task = aioxmpp.utils.LazyTask(
|
||||
self.lookup_info,
|
||||
presence.from_,
|
||||
keys,
|
||||
)
|
||||
self.disco_client.set_info_future(
|
||||
presence.from_,
|
||||
None,
|
||||
lookup_task
|
||||
)
|
||||
|
||||
return presence
|
||||
|
||||
def _push_hashset(self, node, hashset):
|
||||
if self.__active_hashsets and hashset == self.__active_hashsets[-1]:
|
||||
return False
|
||||
|
||||
for group in hashset.values():
|
||||
for key in group:
|
||||
if not self.__key_users[key.node]:
|
||||
self.disco_server.mount_node(key.node, node)
|
||||
self.__key_users[key.node] += 1
|
||||
self.__active_hashsets.append(hashset)
|
||||
|
||||
for expired in self.__active_hashsets[:-3]:
|
||||
for group in expired.values():
|
||||
for key in group:
|
||||
self.__key_users[key.node] -= 1
|
||||
if not self.__key_users[key.node]:
|
||||
self.disco_server.unmount_node(key.node)
|
||||
del self.__key_users[key.node]
|
||||
|
||||
del self.__active_hashsets[:-3]
|
||||
|
||||
return True
|
||||
|
||||
def update_hash(self):
|
||||
node = disco.StaticNode.clone(self.disco_server)
|
||||
info = node.as_info_xso()
|
||||
|
||||
new_hashset = {}
|
||||
|
||||
if self.xep115_support:
|
||||
new_hashset[self.__115] = set(self.__115.calculate_keys(info))
|
||||
|
||||
if self.xep390_support:
|
||||
new_hashset[self.__390] = set(self.__390.calculate_keys(info))
|
||||
|
||||
self.logger.debug("new hashset=%r", new_hashset)
|
||||
|
||||
if self._push_hashset(node, new_hashset):
|
||||
self.on_ver_changed()
|
||||
|
||||
# declare those at the bottom so that on_ver_changed gets emitted when the
|
||||
# service is instantiated
|
||||
_xep115_feature = disco.register_feature(namespaces.xep0115_caps)
|
||||
_xep390_feature = disco.register_feature(namespaces.xep0390_caps)
|
||||
|
||||
|
||||
def writeback(path, captured_events):
|
||||
aioxmpp.utils.mkdir_exist_ok(path.parent)
|
||||
with tempfile.NamedTemporaryFile(dir=str(path.parent),
|
||||
delete=False) as tmpf:
|
||||
try:
|
||||
generator = aioxmpp.xml.XMPPXMLGenerator(
|
||||
tmpf,
|
||||
short_empty_elements=True)
|
||||
generator.startDocument()
|
||||
aioxmpp.xso.events_to_sax(captured_events, generator)
|
||||
generator.endDocument()
|
||||
except: # NOQA
|
||||
os.unlink(tmpf.name)
|
||||
raise
|
||||
os.replace(tmpf.name, str(path))
|
||||
@@ -0,0 +1,82 @@
|
||||
########################################################################
|
||||
# File name: xso.py
|
||||
# This file is part of: aioxmpp
|
||||
#
|
||||
# LICENSE
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU Lesser 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
|
||||
# Lesser General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU Lesser General Public
|
||||
# License along with this program. If not, see
|
||||
# <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
########################################################################
|
||||
import aioxmpp.hashes
|
||||
import aioxmpp.stanza as stanza
|
||||
import aioxmpp.xso as xso
|
||||
|
||||
from aioxmpp.utils import namespaces
|
||||
|
||||
|
||||
namespaces.xep0115_caps = "http://jabber.org/protocol/caps"
|
||||
namespaces.xep0390_caps = "urn:xmpp:caps"
|
||||
|
||||
|
||||
class Caps115(xso.XSO):
|
||||
"""
|
||||
An entity capabilities extension for :class:`~.Presence`.
|
||||
|
||||
.. attribute:: node
|
||||
|
||||
The indicated node, for use with the corresponding info query.
|
||||
|
||||
.. attribute:: hash_
|
||||
|
||||
The hash algorithm used. This is :data:`None` if the legacy format is
|
||||
used.
|
||||
|
||||
.. attribute:: ver
|
||||
|
||||
The version (in the legacy format) or the calculated hash.
|
||||
|
||||
.. attribute:: ext
|
||||
|
||||
Only there for backwards compatibility. Not used anymore.
|
||||
|
||||
"""
|
||||
TAG = (namespaces.xep0115_caps, "c")
|
||||
|
||||
node = xso.Attr("node")
|
||||
|
||||
hash_ = xso.Attr(
|
||||
"hash",
|
||||
validator=xso.Nmtoken(),
|
||||
validate=xso.ValidateMode.FROM_CODE,
|
||||
default=None # to check for legacy
|
||||
)
|
||||
|
||||
ver = xso.Attr("ver")
|
||||
|
||||
ext = xso.Attr("ext", default=None)
|
||||
|
||||
def __init__(self, node, ver, hash_):
|
||||
super().__init__()
|
||||
self.node = node
|
||||
self.ver = ver
|
||||
self.hash_ = hash_
|
||||
|
||||
|
||||
class Caps390(aioxmpp.hashes.HashesParent, xso.XSO):
|
||||
TAG = namespaces.xep0390_caps, "c"
|
||||
|
||||
|
||||
stanza.Presence.xep0115_caps = xso.Child([Caps115])
|
||||
stanza.Presence.xep0390_caps = xso.Child([Caps390])
|
||||
Reference in New Issue
Block a user