caps.py 12.7 KB
Newer Older
Lance Stout's avatar
Lance Stout committed
1

2
3
4
5
# Slixmpp: The Slick XMPP Library
# Copyright (C) 2011 Nathanael C. Fritz, Lance J.T. Stout
# This file is part of Slixmpp.
# See the file LICENSE for copying permission.
Lance Stout's avatar
Lance Stout committed
6
7
8
9
import logging
import hashlib
import base64

mathieui's avatar
mathieui committed
10
11
from asyncio import Future

louiz’'s avatar
louiz’ committed
12
13
14
15
16
from slixmpp import __version__
from slixmpp.stanza import StreamFeatures, Presence, Iq
from slixmpp.xmlstream import register_stanza_plugin, JID
from slixmpp.xmlstream.handler import Callback
from slixmpp.xmlstream.matcher import StanzaPath
17
from slixmpp.util import MemoryCache
18
from slixmpp import asyncio
louiz’'s avatar
louiz’ committed
19
20
21
from slixmpp.exceptions import XMPPError, IqError, IqTimeout
from slixmpp.plugins import BasePlugin
from slixmpp.plugins.xep_0115 import stanza, StaticCaps
Lance Stout's avatar
Lance Stout committed
22
23
24
25
26


log = logging.getLogger(__name__)


Lance Stout's avatar
Lance Stout committed
27
class XEP_0115(BasePlugin):
Lance Stout's avatar
Lance Stout committed
28
29

    """
Link Mauve's avatar
Link Mauve committed
30
    XEP-0115: Entity Capabilities
Lance Stout's avatar
Lance Stout committed
31
32
    """

Lance Stout's avatar
Lance Stout committed
33
34
    name = 'xep_0115'
    description = 'XEP-0115: Entity Capabilities'
35
    dependencies = {'xep_0030', 'xep_0128', 'xep_0004'}
Lance Stout's avatar
Lance Stout committed
36
    stanza = stanza
37
38
39
    default_config = {
        'hash': 'sha-1',
        'caps_node': None,
40
41
        'broadcast': True,
        'cache': None,
42
    }
Lance Stout's avatar
Lance Stout committed
43

Lance Stout's avatar
Lance Stout committed
44
    def plugin_init(self):
Lance Stout's avatar
Lance Stout committed
45
        self.hashes = {'sha-1': hashlib.sha1,
46
                       'sha1': hashlib.sha1,
Lance Stout's avatar
Lance Stout committed
47
48
49
                       'md5': hashlib.md5}

        if self.caps_node is None:
louiz’'s avatar
louiz’ committed
50
            self.caps_node = 'http://slixmpp.com/ver/%s' % __version__
Lance Stout's avatar
Lance Stout committed
51

52
53
54
        if self.cache is None:
            self.cache = MemoryCache()

Lance Stout's avatar
Lance Stout committed
55
        register_stanza_plugin(Presence, stanza.Capabilities)
Lance Stout's avatar
Lance Stout committed
56
        register_stanza_plugin(StreamFeatures, stanza.Capabilities)
Lance Stout's avatar
Lance Stout committed
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71

        self._disco_ops = ['cache_caps',
                           'get_caps',
                           'assign_verstring',
                           'get_verstring',
                           'supports',
                           'has_identity']

        self.xmpp.register_handler(
                Callback('Entity Capabilites',
                         StanzaPath('presence/caps'),
                         self._handle_caps))

        self.xmpp.add_filter('out', self._filter_add_caps)

72
        self.xmpp.add_event_handler('entity_caps', self._process_caps)
Lance Stout's avatar
Lance Stout committed
73

74
75
76
77
78
        if not self.xmpp.is_component:
            self.xmpp.register_feature('caps',
                    self._handle_caps_feature,
                    restart=False,
                    order=10010)
Lance Stout's avatar
Lance Stout committed
79

80
81
        disco = self.xmpp['xep_0030']
        self.static = StaticCaps(self.xmpp, disco.static)
Lance Stout's avatar
Lance Stout committed
82
83

        for op in self._disco_ops:
84
            self.api.register(getattr(self.static, op), op, default=True)
Lance Stout's avatar
Lance Stout committed
85

86
87
88
        for op in ('supports', 'has_identity'):
            self.xmpp['xep_0030'].api.register(getattr(self.static, op), op)

89
        self._run_node_handler = disco._run_node_handler
Lance Stout's avatar
Lance Stout committed
90

91
92
93
94
        disco.cache_caps = self.cache_caps
        disco.update_caps = self.update_caps
        disco.assign_verstring = self.assign_verstring
        disco.get_verstring = self.get_verstring
Lance Stout's avatar
Lance Stout committed
95

96
97
98
99
100
101
102
103
104
105
106
107
108
    def plugin_end(self):
        self.xmpp['xep_0030'].del_feature(feature=stanza.Capabilities.namespace)
        self.xmpp.del_filter('out', self._filter_add_caps)
        self.xmpp.del_event_handler('entity_caps', self._process_caps)
        self.xmpp.remove_handler('Entity Capabilities')
        if not self.xmpp.is_component:
            self.xmpp.unregister_feature('caps', 10010)
        for op in ('supports', 'has_identity'):
            self.xmpp['xep_0030'].restore_defaults(op)

    def session_bind(self, jid):
        self.xmpp['xep_0030'].add_feature(stanza.Capabilities.namespace)

mathieui's avatar
mathieui committed
109
    async def _filter_add_caps(self, stanza):
110
111
112
113
114
115
        if not isinstance(stanza, Presence) or not self.broadcast:
            return stanza

        if stanza['type'] not in ('available', 'chat', 'away', 'dnd', 'xa'):
            return stanza

mathieui's avatar
mathieui committed
116
        ver = await self.get_verstring(stanza['from'])
117
118
119
120
        if ver:
            stanza['caps']['node'] = self.caps_node
            stanza['caps']['hash'] = self.hash
            stanza['caps']['ver'] = ver
Lance Stout's avatar
Lance Stout committed
121
122
123
124
125
126
127
128
        return stanza

    def _handle_caps(self, presence):
        if not self.xmpp.is_component:
            if presence['from'] == self.xmpp.boundjid:
                return
        self.xmpp.event('entity_caps', presence)

Lance Stout's avatar
Lance Stout committed
129
130
131
132
133
134
135
136
137
138
    def _handle_caps_feature(self, features):
        # We already have a method to process presence with
        # caps, so wrap things up and use that.
        p = Presence()
        p['from'] = self.xmpp.boundjid.domain
        p.append(features['caps'])
        self.xmpp.features.add('caps')

        self.xmpp.event('entity_caps', p)

139
    async def _process_caps(self, pres):
Lance Stout's avatar
Lance Stout committed
140
        if not pres['caps']['hash']:
141
142
143
144
            log.debug("Received unsupported legacy caps: %s, %s, %s",
                    pres['caps']['node'],
                    pres['caps']['ver'],
                    pres['caps']['ext'])
Lance Stout's avatar
Lance Stout committed
145
146
147
            self.xmpp.event('entity_caps_legacy', pres)
            return

148
149
        ver = pres['caps']['ver']

mathieui's avatar
mathieui committed
150
        existing_verstring = await self.get_verstring(pres['from'].full)
151
        if str(existing_verstring) == str(ver):
Lance Stout's avatar
Lance Stout committed
152
            return
Lance Stout's avatar
Lance Stout committed
153

mathieui's avatar
mathieui committed
154
        existing_caps = await self.get_caps(verstring=ver)
155
        if existing_caps is not None:
mathieui's avatar
mathieui committed
156
            await self.assign_verstring(pres['from'], ver)
157
158
            return

159
160
        ifrom = pres['to'] if self.xmpp.is_component else None

Lance Stout's avatar
Lance Stout committed
161
162
163
        if pres['caps']['hash'] not in self.hashes:
            try:
                log.debug("Unknown caps hash: %s", pres['caps']['hash'])
164
                self.xmpp['xep_0030'].get_info(jid=pres['from'], ifrom=ifrom)
Lance Stout's avatar
Lance Stout committed
165
166
167
                return
            except XMPPError:
                return
Lance Stout's avatar
Lance Stout committed
168

169
        log.debug("New caps verification string: %s", ver)
Lance Stout's avatar
Lance Stout committed
170
        try:
171
            node = '%s#%s' % (pres['caps']['node'], ver)
172
            caps = await self.xmpp['xep_0030'].get_info(pres['from'], node,
173
                                                             ifrom=ifrom)
174
175
176

            if isinstance(caps, Iq):
                caps = caps['disco_info']
Lance Stout's avatar
Lance Stout committed
177

mathieui's avatar
mathieui committed
178
179
180
            if await self._validate_caps(caps, pres['caps']['hash'],
                                               pres['caps']['ver']):
                await self.assign_verstring(pres['from'], pres['caps']['ver'])
Lance Stout's avatar
Lance Stout committed
181
        except XMPPError:
182
183
            log.debug("Could not retrieve disco#info results for caps for %s", node)

mathieui's avatar
mathieui committed
184
    async def _validate_caps(self, caps, hash, check_verstring):
Lance Stout's avatar
Lance Stout committed
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
        # Check Identities
        full_ids = caps.get_identities(dedupe=False)
        deduped_ids = caps.get_identities()
        if len(full_ids) != len(deduped_ids):
            log.debug("Duplicate disco identities found, invalid for caps")
            return False

        # Check Features
        full_features = caps.get_features(dedupe=False)
        deduped_features = caps.get_features()
        if len(full_features) != len(deduped_features):
            log.debug("Duplicate disco features found, invalid for caps")
            return False

        # Check Forms
        form_types = []
        deduped_form_types = set()
        for stanza in caps['substanzas']:
Lance Stout's avatar
Lance Stout committed
203
204
205
206
            if not isinstance(stanza, self.xmpp['xep_0004'].stanza.Form):
                log.debug("Non form extension found, ignoring for caps")
                caps.xml.remove(stanza.xml)
                continue
207
208
            if 'FORM_TYPE' in stanza.get_fields():
                f_type = tuple(stanza.get_fields()['FORM_TYPE']['value'])
Lance Stout's avatar
Lance Stout committed
209
210
211
212
213
214
215
216
217
218
219
                form_types.append(f_type)
                deduped_form_types.add(f_type)
                if len(form_types) != len(deduped_form_types):
                    log.debug("Duplicated FORM_TYPE values, " + \
                              "invalid for caps")
                    return False

                if len(f_type) > 1:
                    deduped_type = set(f_type)
                    if len(f_type) != len(deduped_type):
                        log.debug("Extra FORM_TYPE data, invalid for caps")
Lance Stout's avatar
Lance Stout committed
220
221
                        return False

222
                if stanza.get_fields()['FORM_TYPE']['type'] != 'hidden':
Lance Stout's avatar
Lance Stout committed
223
224
                    log.debug("Field FORM_TYPE type not 'hidden', " + \
                              "ignoring form for caps")
Lance Stout's avatar
Lance Stout committed
225
                    caps.xml.remove(stanza.xml)
Lance Stout's avatar
Lance Stout committed
226
227
228
            else:
                log.debug("No FORM_TYPE found, ignoring form for caps")
                caps.xml.remove(stanza.xml)
Lance Stout's avatar
Lance Stout committed
229
230
231
232
233
234
235

        verstring = self.generate_verstring(caps, hash)
        if verstring != check_verstring:
            log.debug("Verification strings do not match: %s, %s" % (
                verstring, check_verstring))
            return False

mathieui's avatar
mathieui committed
236
        await self.cache_caps(verstring, caps)
Lance Stout's avatar
Lance Stout committed
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
        return True

    def generate_verstring(self, info, hash):
        hash = self.hashes.get(hash, None)
        if hash is None:
            return None

        S = ''

        # Convert None to '' in the identities
        def clean_identity(id):
            return map(lambda i: i or '', id)
        identities = map(clean_identity, info['identities'])

        identities = sorted(('/'.join(i) for i in identities))
        features = sorted(info['features'])
Lance Stout's avatar
Lance Stout committed
253

Lance Stout's avatar
Lance Stout committed
254
255
256
257
258
259
260
        S += '<'.join(identities) + '<'
        S += '<'.join(features) + '<'

        form_types = {}

        for stanza in info['substanzas']:
            if isinstance(stanza, self.xmpp['xep_0004'].stanza.Form):
261
                if 'FORM_TYPE' in stanza.get_fields():
Lance Stout's avatar
Lance Stout committed
262
263
264
265
266
267
268
269
270
271
272
                    f_type = stanza['values']['FORM_TYPE']
                    if len(f_type):
                        f_type = f_type[0]
                    if f_type not in form_types:
                        form_types[f_type] = []
                    form_types[f_type].append(stanza)

        sorted_forms = sorted(form_types.keys())
        for f_type in sorted_forms:
            for form in form_types[f_type]:
                S += '%s<' % f_type
273
                fields = sorted(form.get_fields().keys())
Lance Stout's avatar
Lance Stout committed
274
275
276
                fields.remove('FORM_TYPE')
                for field in fields:
                    S += '%s<' % field
277
                    vals = form.get_fields()[field].get_value(convert=False)
Lance Stout's avatar
Lance Stout committed
278
279
280
281
282
283
284
285
                    if vals is None:
                        S += '<'
                    else:
                        if not isinstance(vals, list):
                            vals = [vals]
                        S += '<'.join(sorted(vals)) + '<'

        binary = hash(S.encode('utf8')).digest()
286
        return base64.b64encode(binary).decode('utf-8')
Lance Stout's avatar
Lance Stout committed
287

288
    async def update_caps(self, jid=None, node=None, preserve=False):
Lance Stout's avatar
Lance Stout committed
289
        try:
290
            info = await self.xmpp['xep_0030'].get_info(jid, node, local=True)
Lance Stout's avatar
Lance Stout committed
291
292
293
            if isinstance(info, Iq):
                info = info['disco_info']
            ver = self.generate_verstring(info, self.hash)
mathieui's avatar
mathieui committed
294
295
296
297
298
299
300
            await self.xmpp['xep_0030'].set_info(
                jid=jid,
                node='%s#%s' % (self.caps_node, ver),
                info=info
            )
            await self.cache_caps(ver, info)
            await self.assign_verstring(jid, ver)
301

louiz’'s avatar
louiz’ committed
302
            if self.xmpp.sessionstarted and self.broadcast:
303
                if self.xmpp.is_component or preserve:
304
305
                    for contact in self.xmpp.roster[jid]:
                        self.xmpp.roster[jid][contact].send_last_presence()
306
307
                else:
                    self.xmpp.roster[jid].send_last_presence()
Lance Stout's avatar
Lance Stout committed
308
309
        except XMPPError:
            return
Lance Stout's avatar
Lance Stout committed
310

mathieui's avatar
mathieui committed
311
312
313
314
315
316
    def get_verstring(self, jid=None) -> Future:
        """Get the stored verstring for a JID.

        .. versionchanged:: 1.8.0
            This function now returns a Future.
        """
Lance Stout's avatar
Lance Stout committed
317
318
        if jid in ('', None):
            jid = self.xmpp.boundjid.full
319
320
        if isinstance(jid, JID):
            jid = jid.full
321
        return self.api['get_verstring'](jid)
Lance Stout's avatar
Lance Stout committed
322

mathieui's avatar
mathieui committed
323
324
325
326
327
328
    def assign_verstring(self, jid=None, verstring=None) -> Future:
        """Assign a vertification string to a jid.

        .. versionchanged:: 1.8.0
            This function now returns a Future.
        """
Lance Stout's avatar
Lance Stout committed
329
330
        if jid in (None, ''):
            jid = self.xmpp.boundjid.full
331
332
        if isinstance(jid, JID):
            jid = jid.full
333
        return self.api['assign_verstring'](jid, args={
mathieui's avatar
mathieui committed
334
335
            'verstring': verstring
        })
Lance Stout's avatar
Lance Stout committed
336

mathieui's avatar
mathieui committed
337
338
339
340
341
342
    def cache_caps(self, verstring=None, info=None) -> Future:
        """Add caps to the cache.

        .. versionchanged:: 1.8.0
            This function now returns a Future.
        """
Lance Stout's avatar
Lance Stout committed
343
        data = {'verstring': verstring, 'info': info}
344
        return self.api['cache_caps'](args=data)
Lance Stout's avatar
Lance Stout committed
345

mathieui's avatar
mathieui committed
346
347
348
349
350
351
    async def get_caps(self, jid=None, verstring=None):
        """Get caps for a JID.

        .. versionchanged:: 1.8.0
            This function is now a coroutine.
        """
Lance Stout's avatar
Lance Stout committed
352
353
        if verstring is None:
            if jid is not None:
mathieui's avatar
mathieui committed
354
                verstring = await self.get_verstring(jid)
Lance Stout's avatar
Lance Stout committed
355
356
            else:
                return None
357
358
        if isinstance(jid, JID):
            jid = jid.full
Lance Stout's avatar
Lance Stout committed
359
        data = {'verstring': verstring}
mathieui's avatar
mathieui committed
360
        return await self.api['get_caps'](jid, args=data)