caps_cache.py 15.7 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
# Copyright (C) 2007 Tomasz Melcer <liori AT exroot.org>
#                    Travis Shirk <travis AT pobox.com>
# Copyright (C) 2007-2014 Yann Leboulanger <asterix AT lagaule.org>
# Copyright (C) 2008 Brendan Taylor <whateley AT gmail.com>
#                    Jonathan Schleifer <js-gajim AT webkeks.org>
# Copyright (C) 2008-2009 Stephan Erb <steve-e AT h3c.de>
#
# This file is part of Gajim.
#
# Gajim is free software; you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published
# by the Free Software Foundation; version 3 only.
#
# Gajim 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 General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
Liorithiel's avatar
Liorithiel committed
21

22 23
"""
Module containing all XEP-115 (Entity Capabilities) related classes
24 25 26

Basic Idea:
CapsCache caches features to hash relationships. The cache is queried
27 28
through ClientCaps objects which are hold by contact instances.
"""
29

30 31
import base64
import hashlib
32 33
import logging

Philipp Hörist's avatar
Philipp Hörist committed
34
import nbxmpp
Philipp Hörist's avatar
Philipp Hörist committed
35
from nbxmpp.const import Affiliation
36
from nbxmpp import (NS_XHTML_IM, NS_ESESSION, NS_CHATSTATES,
Philipp Hörist's avatar
Philipp Hörist committed
37
    NS_JINGLE_ICE_UDP, NS_JINGLE_RTP_AUDIO, NS_JINGLE_RTP_VIDEO,
38
    NS_JINGLE_FILE_TRANSFER_5)
steve-e's avatar
steve-e committed
39
# Features where we cannot safely assume that the other side supports them
40
FEATURE_BLACKLIST = [NS_CHATSTATES, NS_XHTML_IM, NS_ESESSION,
41
    NS_JINGLE_ICE_UDP, NS_JINGLE_RTP_AUDIO, NS_JINGLE_RTP_VIDEO,
42
    NS_JINGLE_FILE_TRANSFER_5]
André's avatar
André committed
43

44 45
log = logging.getLogger('gajim.c.caps_cache')

46 47 48 49
# Query entry status codes
NEW = 0
QUERIED = 1
CACHED = 2 # got the answer
50
FAKED = 3 # allow NullClientCaps to behave as it has a cached item
51

52 53 54 55
################################################################################
### Public API of this module
################################################################################

56
capscache = None
Philipp Hörist's avatar
Philipp Hörist committed
57
muc_caps_cache = None
58
def initialize(logger):
59 60 61 62
    """
    Initialize this module
    """
    global capscache
Philipp Hörist's avatar
Philipp Hörist committed
63
    global muc_caps_cache
64
    capscache = CapsCache(logger)
Philipp Hörist's avatar
Philipp Hörist committed
65
    muc_caps_cache = MucCapsCache()
66

67
def client_supports(client_caps, requested_feature):
68 69 70 71 72 73
    lookup_item = client_caps.get_cache_lookup_strategy()
    cache_item = lookup_item(capscache)

    supported_features = cache_item.features
    if requested_feature in supported_features:
        return True
74
    if not supported_features and cache_item.status in (NEW, QUERIED, FAKED):
75 76 77
        # assume feature is supported, if we don't know yet, what the client
        # is capable of
        return requested_feature not in FEATURE_BLACKLIST
78
    return False
79

80 81 82 83 84 85 86 87
def get_client_identity(client_caps):
    lookup_item = client_caps.get_cache_lookup_strategy()
    cache_item = lookup_item(capscache)

    for identity in cache_item.identities:
        if identity.get('category') == 'client':
            return identity.get('type')

88
def create_suitable_client_caps(node, caps_hash, hash_method, fjid=None):
89 90 91 92 93
    """
    Create and return a suitable ClientCaps object for the given node,
    caps_hash, hash_method combination.
    """
    if not node or not caps_hash:
94 95 96 97 98
        if fjid:
            client_caps = NoClientCaps(fjid)
        else:
            # improper caps, ignore client capabilities.
            client_caps = NullClientCaps()
99 100 101 102 103
    elif not hash_method:
        client_caps = OldClientCaps(caps_hash, node)
    else:
        client_caps = ClientCaps(caps_hash, node, hash_method)
    return client_caps
104

105
def compute_caps_hash(identities, features, dataforms=None, hash_method='sha-1'):
106 107 108
    """
    Compute caps hash according to XEP-0115, V1.5
    """
109 110
    if dataforms is None:
        dataforms = []
André's avatar
André committed
111 112

    def sort_identities_key(i):
113
        return (i.category, i.type, i.lang or '')
André's avatar
André committed
114 115

    def sort_dataforms_key(dataform):
116
        return dataform['FORM_TYPE'].value
117 118

    S = ''
André's avatar
André committed
119
    identities.sort(key=sort_identities_key)
120
    for i in identities:
121 122 123 124
        c = i.category
        type_ = i.type
        lang = i.lang or ''
        name = i.name or ''
125 126 127 128
        S += '%s/%s/%s/%s<' % (c, type_, lang, name)
    features.sort()
    for f in features:
        S += '%s<' % f
André's avatar
André committed
129
    dataforms.sort(key=sort_dataforms_key)
130 131 132
    for dataform in dataforms:
        # fields indexed by var
        fields = {}
133 134 135
        for f in dataform.iter_fields():
            values = f.getTags('value')
            fields[f.var] = [value.getData() for value in values]
136 137
        form_type = fields.get('FORM_TYPE')
        if form_type:
138
            S += form_type[0] + '<'
139 140 141
            del fields['FORM_TYPE']
        for var in sorted(fields.keys()):
            S += '%s<' % var
142
            values = sorted(fields[var])
143 144 145 146
            for value in values:
                S += '%s<' % value

    if hash_method == 'sha-1':
147
        hash_ = hashlib.sha1(S.encode('utf-8'))
148
    elif hash_method == 'md5':
149
        hash_ = hashlib.md5(S.encode('utf-8'))
150 151
    else:
        return ''
Yann Leboulanger's avatar
Yann Leboulanger committed
152
    return base64.b64encode(hash_.digest()).decode('utf-8')
153

154

155 156 157 158
################################################################################
### Internal classes of this module
################################################################################

159
class AbstractClientCaps:
160 161 162 163 164 165 166
    """
    Base class representing a client and its capabilities as advertised by a
    caps tag in a presence
    """
    def __init__(self, caps_hash, node):
        self._hash = caps_hash
        self._node = node
167
        self._hash_method = None
168 169 170 171 172 173

    def get_discover_strategy(self):
        return self._discover

    def _discover(self, connection, jid):
        """
Alexander Krotov's avatar
Alexander Krotov committed
174
        To be implemented by subclasses
175 176 177 178 179 180 181 182
        """
        raise NotImplementedError

    def get_cache_lookup_strategy(self):
        return self._lookup_in_cache

    def _lookup_in_cache(self, caps_cache):
        """
Alexander Krotov's avatar
Alexander Krotov committed
183
        To be implemented by subclasses
184 185 186 187 188 189 190 191
        """
        raise NotImplementedError

    def get_hash_validation_strategy(self):
        return self._is_hash_valid

    def _is_hash_valid(self, identities, features, dataforms):
        """
Alexander Krotov's avatar
Alexander Krotov committed
192
        To be implemented by subclasses
193 194
        """
        raise NotImplementedError
195

196

steve-e's avatar
steve-e committed
197
class ClientCaps(AbstractClientCaps):
198 199 200 201 202 203 204
    """
    The current XEP-115 implementation
    """
    def __init__(self, caps_hash, node, hash_method):
        AbstractClientCaps.__init__(self, caps_hash, node)
        assert hash_method != 'old'
        self._hash_method = hash_method
205

206 207
    def _lookup_in_cache(self, caps_cache):
        return caps_cache[(self._hash_method, self._hash)]
208

209
    def _discover(self, connection, jid):
210 211
        connection.get_module('Discovery').disco_contact(
            jid, '%s#%s' % (self._node, self._hash))
212

213
    def _is_hash_valid(self, identities, features, dataforms):
214 215 216
        computed_hash = compute_caps_hash(
            identities, features, dataforms=dataforms,
            hash_method=self._hash_method)
217
        return computed_hash == self._hash
218

219

steve-e's avatar
steve-e committed
220
class OldClientCaps(AbstractClientCaps):
221
    """
Alexander Krotov's avatar
Alexander Krotov committed
222
    Old XEP-115 implementation. Kept around for background compatibility
223 224 225
    """
    def __init__(self, caps_hash, node):
        AbstractClientCaps.__init__(self, caps_hash, node)
226
        self._hash_method = 'old'
227

228 229
    def _lookup_in_cache(self, caps_cache):
        return caps_cache[('old', self._node + '#' + self._hash)]
230

231
    def _discover(self, connection, jid):
232
        connection.get_module('Discovery').disco_contact(jid)
233

234 235
    def _is_hash_valid(self, identities, features, dataforms):
        return True
236

237 238 239 240 241 242 243 244 245 246 247 248
class NoClientCaps(AbstractClientCaps):
    """
    For clients that don't support XEP-0115
    """
    def __init__(self, fjid):
        AbstractClientCaps.__init__(self, fjid, fjid)
        self._hash_method = 'no'

    def _lookup_in_cache(self, caps_cache):
        return caps_cache[('no', self._node)]

    def _discover(self, connection, jid):
249
        connection.get_module('Discovery').disco_contact(jid)
250 251 252

    def _is_hash_valid(self, identities, features, dataforms):
        return True
253

steve-e's avatar
steve-e committed
254
class NullClientCaps(AbstractClientCaps):
255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272
    """
    This is a NULL-Object to streamline caps handling if a client has not
    advertised any caps or has advertised them in an improper way

    Assumes (almost) everything is supported.
    """
    _instance = None
    def __new__(cls, *args, **kwargs):
        """
        Make it a singleton.
        """
        if not cls._instance:
            cls._instance = super(NullClientCaps, cls).__new__(
                    cls, *args, **kwargs)
        return cls._instance

    def __init__(self):
        AbstractClientCaps.__init__(self, None, None)
273
        self._hash_method = 'dummy'
274 275 276 277 278 279 280 281 282 283 284 285 286

    def _lookup_in_cache(self, caps_cache):
        # lookup something which does not exist to get a new CacheItem created
        cache_item = caps_cache[('dummy', '')]
        # Mark the item as cached so that protocol/caps.py does not update it
        cache_item.status = FAKED
        return cache_item

    def _discover(self, connection, jid):
        pass

    def _is_hash_valid(self, identities, features, dataforms):
        return False
287

288

289
class CapsCache:
290 291 292 293 294 295 296 297 298 299 300 301
    """
    This object keeps the mapping between caps data and real disco features they
    represent, and provides simple way to query that info
    """
    def __init__(self, logger=None):
        # our containers:
        # __cache is a dictionary mapping: pair of hash method and hash maps
        #   to CapsCacheItem object
        # __CacheItem is a class that stores data about particular
        #   client (hash method/hash pair)
        self.__cache = {}

302
        class CacheItem:
303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329
            # __names is a string cache; every string long enough is given
            #   another object, and we will have plenty of identical long
            #   strings. therefore we can cache them
            __names = {}

            def __init__(self, hash_method, hash_, logger):
                # cached into db
                self.hash_method = hash_method
                self.hash = hash_
                self._features = []
                self._identities = []
                self._logger = logger

                self.status = NEW
                self._recently_seen = False

            def _get_features(self):
                return self._features

            def _set_features(self, value):
                self._features = []
                for feature in value:
                    self._features.append(self.__names.setdefault(feature, feature))

            features = property(_get_features, _set_features)

            def _get_identities(self):
330
                return self._identities
331 332

            def _set_identities(self, value):
333
                self._identities = value
334 335 336 337 338 339

            identities = property(_get_identities, _set_identities)

            def set_and_store(self, identities, features):
                self.identities = identities
                self.features = features
340 341
                if self.hash_method != 'no':
                    self._logger.add_caps_entry(self.hash_method, self.hash,
342 343 344 345 346 347
                        identities, features)
                self.status = CACHED

            def update_last_seen(self):
                if not self._recently_seen:
                    self._recently_seen = True
348 349 350
                    if self.hash_method != 'no':
                        self._logger.update_caps_time(self.hash_method,
                            self.hash)
351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402

            def is_valid(self):
                """
                Returns True if identities and features for this cache item
                are known.
                """
                return self.status in (CACHED, FAKED)

        self.__CacheItem = CacheItem
        self.logger = logger

    def initialize_from_db(self):
        self._remove_outdated_caps()
        for hash_method, hash_, identities, features in \
        self.logger.iter_caps_data():
            x = self[(hash_method, hash_)]
            x.identities = identities
            x.features = features
            x.status = CACHED

    def _remove_outdated_caps(self):
        """
        Remove outdated values from the db
        """
        self.logger.clean_caps_table()

    def __getitem__(self, caps):
        if caps in self.__cache:
            return self.__cache[caps]

        hash_method, hash_ = caps

        x = self.__CacheItem(hash_method, hash_, self.logger)
        self.__cache[(hash_method, hash_)] = x
        return x

    def query_client_of_jid_if_unknown(self, connection, jid, client_caps):
        """
        Start a disco query to determine caps (node, ver, exts). Won't query if
        the data is already in cache
        """
        lookup_cache_item = client_caps.get_cache_lookup_strategy()
        q = lookup_cache_item(self)

        if q.status == NEW:
            # do query for bare node+hash pair
            # this will create proper object
            q.status = QUERIED
            discover = client_caps.get_discover_strategy()
            discover(connection, jid)
        else:
            q.update_last_seen()
403 404 405

    def forget_caps(self, client_caps):
        hash_method = client_caps._hash_method
406 407
        hash_ = client_caps._hash
        key = (hash_method, hash_)
408 409
        if key in self.__cache:
            del self.__cache[key]
Philipp Hörist's avatar
Philipp Hörist committed
410 411 412 413 414 415 416


class MucCapsCache:

    def __init__(self):
        self.cache = {}

417 418
    def append(self, info):
        if nbxmpp.NS_MUC not in info.features:
Alexander Krotov's avatar
Alexander Krotov committed
419
            # Not a MUC, don't cache info
420 421
            return

422
        self.cache[info.jid] = info
Philipp Hörist's avatar
Philipp Hörist committed
423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438

    def is_cached(self, jid):
        return jid in self.cache

    def supports(self, jid, feature):
        if jid in self.cache:
            if feature in self.cache[jid].features:
                return True
        return False

    def has_mam(self, jid):
        try:
            if nbxmpp.NS_MAM_2 in self.cache[jid].features:
                return True
            if nbxmpp.NS_MAM_1 in self.cache[jid].features:
                return True
439
            return False
Philipp Hörist's avatar
Philipp Hörist committed
440 441
        except (KeyError, AttributeError):
            return False
442 443 444 445 446

    def get_mam_namespace(self, jid):
        try:
            if nbxmpp.NS_MAM_2 in self.cache[jid].features:
                return nbxmpp.NS_MAM_2
447
            if nbxmpp.NS_MAM_1 in self.cache[jid].features:
448 449 450
                return nbxmpp.NS_MAM_1
        except (KeyError, AttributeError):
            return
451 452 453

    def is_subject_change_allowed(self, jid, affiliation):
        allowed = True
Philipp Hörist's avatar
Philipp Hörist committed
454
        if affiliation in (Affiliation.OWNER, Affiliation.ADMIN):
455 456 457
            return allowed

        if jid in self.cache:
458
            for form in self.cache[jid].dataforms:
459 460 461 462 463
                try:
                    allowed = form['muc#roominfo_changesubject'].value
                except KeyError:
                    pass
        return allowed
464

465 466 467 468 469 470
    def is_open(self, jid):
        return 'muc_membersonly' not in self.cache[jid].features

    def is_password_protected(self, jid):
        return 'muc_unsecured' not in self.cache[jid].features

471 472 473
    def is_anonymous(self, jid):
        return 'muc_nonanonymous' not in self.cache[jid].features

474 475 476
    def is_persistent(self, jid):
        return 'muc_temporary' not in self.cache[jid].features

477 478 479
    def get_room_infos(self, jid):
        room_info = {}
        if jid in self.cache:
480
            for form in self.cache[jid].dataforms:
481 482 483 484 485 486 487 488 489 490 491 492 493
                try:
                    room_info['name'] = form['muc#roomconfig_roomname'].value
                except KeyError:
                    room_info['name'] = None
                try:
                    room_info['desc'] = form['muc#roominfo_description'].value
                except KeyError:
                    room_info['desc'] = None
                try:
                    room_info['lang'] = form['muc#roominfo_lang'].value
                except KeyError:
                    room_info['lang'] = None
        return room_info or None