1# Copyright (C) 2020 Philipp Hörist <philipp AT hoerist.com>
2#
3# This file is part of nbxmpp.
4#
5# This program is free software; you can redistribute it and/or
6# modify it under the terms of the GNU General Public License
7# as published by the Free Software Foundation; either version 3
8# of the License, or (at your option) any later version.
9#
10# This program is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with this program; If not, see <http://www.gnu.org/licenses/>.
17
18from collections import namedtuple
19
20from nbxmpp.task import iq_request_task
21from nbxmpp.errors import is_error
22from nbxmpp.errors import PubSubStanzaError
23from nbxmpp.errors import MalformedStanzaError
24from nbxmpp.structs import StanzaHandler
25from nbxmpp.structs import PubSubEventData
26from nbxmpp.structs import CommonResult
27from nbxmpp.protocol import Iq
28from nbxmpp.protocol import Node
29from nbxmpp.namespaces import Namespace
30from nbxmpp.modules.base import BaseModule
31from nbxmpp.modules.util import process_response
32from nbxmpp.modules.util import raise_if_error
33from nbxmpp.modules.util import finalize
34from nbxmpp.modules.dataforms import extend_form
35
36
37class PubSub(BaseModule):
38    def __init__(self, client):
39        BaseModule.__init__(self, client)
40
41        self._client = client
42        self.handlers = [
43            StanzaHandler(name='message',
44                          callback=self._process_pubsub_base,
45                          ns=Namespace.PUBSUB_EVENT,
46                          priority=15),
47        ]
48
49    def _process_pubsub_base(self, _client, stanza, properties):
50        properties.pubsub = True
51        event = stanza.getTag('event', namespace=Namespace.PUBSUB_EVENT)
52
53        delete = event.getTag('delete')
54        if delete is not None:
55            node = delete.getAttr('node')
56            properties.pubsub_event = PubSubEventData(
57                node, deleted=True)
58            return
59
60        purge = event.getTag('purge')
61        if purge is not None:
62            node = purge.getAttr('node')
63            properties.pubsub_event = PubSubEventData(node, purged=True)
64            return
65
66        items = event.getTag('items')
67        if items is not None:
68            node = items.getAttr('node')
69
70            retract = items.getTag('retract')
71            if retract is not None:
72                id_ = retract.getAttr('id')
73                properties.pubsub_event = PubSubEventData(
74                    node, id_, retracted=True)
75                return
76
77            if len(items.getChildren()) != 1:
78                self._log.warning('PubSub event with != 1 item')
79                self._log.warning(stanza)
80                return
81
82            item = items.getTag('item')
83            if item is None:
84                self._log.warning('No item node found')
85                self._log.warning(stanza)
86                return
87            id_ = item.getAttr('id')
88            properties.pubsub_event = PubSubEventData(node, id_, item)
89
90    @iq_request_task
91    def request_item(self, node, id_, jid=None):
92        task = yield
93
94        response = yield _make_pubsub_request(node, id_=id_, jid=jid)
95
96        if response.isError():
97            raise PubSubStanzaError(response)
98
99        item = _get_pubsub_item(response, node, id_)
100        yield task.set_result(item)
101
102    @iq_request_task
103    def request_items(self, node, max_items=None, jid=None):
104        _task = yield
105
106        response = yield _make_pubsub_request(node,
107                                              max_items=max_items,
108                                              jid=jid)
109
110        if response.isError():
111            raise PubSubStanzaError(response)
112
113        yield _get_pubsub_items(response, node)
114
115    @iq_request_task
116    def publish(self,
117                node,
118                item,
119                id_=None,
120                options=None,
121                jid=None,
122                force_node_options=False):
123
124        _task = yield
125
126        request = _make_publish_request(node, item, id_, options, jid)
127        response = yield request
128
129        if response.isError():
130            error = PubSubStanzaError(response)
131            if (not force_node_options or
132                    error.app_condition != 'precondition-not-met'):
133                raise error
134
135            result = yield self.reconfigure_node(node, options, jid)
136            if is_error(result):
137                raise result
138
139            response = yield request
140            if response.isError():
141                raise PubSubStanzaError(response)
142
143        jid = response.getFrom()
144        item_id = _get_published_item_id(response, node, id_)
145        yield PubSubPublishResult(jid, node, item_id)
146
147    @iq_request_task
148    def get_access_model(self, node):
149        _task = yield
150
151        self._log.info('Request access model')
152
153        result = yield self.get_node_configuration(node)
154
155        raise_if_error(result)
156
157        yield result.form['pubsub#access_model'].value
158
159    @iq_request_task
160    def set_access_model(self, node, model):
161        task = yield
162
163        if model not in ('open', 'presence'):
164            raise ValueError('Invalid access model')
165
166        result = yield self.get_node_configuration(node)
167
168        raise_if_error(result)
169
170        try:
171            access_model = result.form['pubsub#access_model'].value
172        except Exception:
173            yield task.set_error('warning',
174                                 condition='access-model-not-supported')
175
176        if access_model == model:
177            jid = self._client.get_bound_jid().new_as_bare()
178            yield CommonResult(jid=jid)
179
180        result.form['pubsub#access_model'].value = model
181
182        self._log.info('Set access model %s', model)
183
184        result = yield self.set_node_configuration(node, result.form)
185
186        yield finalize(task, result)
187
188    @iq_request_task
189    def retract(self, node, id_, jid=None, notify=True):
190        _task = yield
191
192        response = yield _make_retract_request(node, id_, jid, notify)
193        yield process_response(response)
194
195    @iq_request_task
196    def delete(self, node, jid=None):
197        _task = yield
198
199        response = yield _make_delete_request(node, jid)
200        yield process_response(response)
201
202    @iq_request_task
203    def reconfigure_node(self, node, options, jid=None):
204        _task = yield
205
206        result = yield self.get_node_configuration(node, jid)
207        if is_error(result):
208            raise result
209
210        _apply_options(result.form, options)
211        result = yield self.set_node_configuration(node, result.form, jid)
212        yield result
213
214    @iq_request_task
215    def set_node_configuration(self, node, form, jid=None):
216        _task = yield
217
218        response = yield _make_node_configuration(node, form, jid)
219        yield process_response(response)
220
221    @iq_request_task
222    def get_node_configuration(self, node, jid=None):
223        _task = yield
224
225        response = yield _make_node_configuration_request(node, jid)
226
227        if response.isError():
228            raise PubSubStanzaError(response)
229
230        jid = response.getFrom()
231        form = _get_configure_form(response, node)
232        yield PubSubNodeConfigurationResult(jid=jid, node=node, form=form)
233
234
235def get_pubsub_request(jid, node, id_=None, max_items=None):
236    query = Iq('get', to=jid)
237    pubsub = query.addChild('pubsub', namespace=Namespace.PUBSUB)
238    items = pubsub.addChild('items', {'node': node})
239    if max_items is not None:
240        items.setAttr('max_items', max_items)
241    if id_ is not None:
242        items.addChild('item', {'id': id_})
243    return query
244
245
246def get_pubsub_item(stanza):
247    pubsub_node = stanza.getTag('pubsub')
248    items_node = pubsub_node.getTag('items')
249    return items_node.getTag('item')
250
251
252def get_pubsub_items(stanza, node=None):
253    pubsub_node = stanza.getTag('pubsub')
254    items_node = pubsub_node.getTag('items')
255    if node is not None and items_node.getAttr('node') != node:
256        return None
257
258    if items_node is not None:
259        return items_node.getTags('item')
260    return None
261
262
263def get_publish_options(config):
264    options = Node(Namespace.DATA + ' x', attrs={'type': 'submit'})
265    field = options.addChild('field',
266                             attrs={'var': 'FORM_TYPE', 'type': 'hidden'})
267    field.setTagData('value', Namespace.PUBSUB_PUBLISH_OPTIONS)
268
269    for var, value in config.items():
270        field = options.addChild('field', attrs={'var': var})
271        field.setTagData('value', value)
272    return options
273
274
275def _get_pubsub_items(response, node):
276    pubsub_node = response.getTag('pubsub', namespace=Namespace.PUBSUB)
277    if pubsub_node is None:
278        raise MalformedStanzaError('pubsub node missing', response)
279
280    items_node = pubsub_node.getTag('items')
281    if items_node is None:
282        raise MalformedStanzaError('items node missing', response)
283
284    if items_node.getAttr('node') != node:
285        raise MalformedStanzaError('invalid node attr', response)
286
287    return items_node.getTags('item')
288
289
290def _get_pubsub_item(response, node, id_):
291    items = _get_pubsub_items(response, node)
292
293    if len(items) > 1:
294        raise MalformedStanzaError('multiple items found', response)
295
296    if not items:
297        return None
298
299    item = items[0]
300    if item.getAttr('id') != id_:
301        raise MalformedStanzaError('invalid item id', response)
302
303    return item
304
305
306def _make_pubsub_request(node, id_=None, max_items=None, jid=None):
307    query = Iq('get', to=jid)
308    pubsub = query.addChild('pubsub', namespace=Namespace.PUBSUB)
309    items = pubsub.addChild('items', {'node': node})
310    if max_items is not None:
311        items.setAttr('max_items', max_items)
312    if id_ is not None:
313        items.addChild('item', {'id': id_})
314    return query
315
316
317def _get_configure_form(response, node):
318    pubsub = response.getTag('pubsub', namespace=Namespace.PUBSUB_OWNER)
319    if pubsub is None:
320        raise MalformedStanzaError('pubsub node missing', response)
321
322    configure = pubsub.getTag('configure')
323    if configure is None:
324        raise MalformedStanzaError('configure node missing', response)
325
326    if node != configure.getAttr('node'):
327        raise MalformedStanzaError('invalid node attribute', response)
328
329    forms = configure.getTags('x', namespace=Namespace.DATA)
330    for form in forms:
331        dataform = extend_form(node=form)
332        form_type = dataform.vars.get('FORM_TYPE')
333        if form_type is None or form_type.value != Namespace.PUBSUB_CONFIG:
334            continue
335
336        return dataform
337
338    raise MalformedStanzaError('no valid form type found', response)
339
340
341def _get_published_item_id(response, node, id_):
342    pubsub = response.getTag('pubsub', namespace=Namespace.PUBSUB)
343    if pubsub is None:
344        # https://xmpp.org/extensions/xep-0060.html#publisher-publish-success
345        # If the publish request did not include an ItemID,
346        # the IQ-result SHOULD include an empty <item/> element
347        # that specifies the ItemID of the published item.
348        #
349        # If the server did not add a payload we assume the item was
350        # published with the id we requested
351        return id_
352
353    publish = pubsub.getTag('publish')
354    if publish is None:
355        raise MalformedStanzaError('publish node missing', response)
356
357    if node != publish.getAttr('node'):
358        raise MalformedStanzaError('invalid node attribute', response)
359
360    item = publish.getTag('item')
361    if item is None:
362        raise MalformedStanzaError('item node missing', response)
363
364    item_id = item.getAttr('id')
365    if id_ is not None and item_id != id_:
366        raise MalformedStanzaError('invalid item id', response)
367
368    return item_id
369
370
371def _make_publish_request(node, item, id_, options, jid):
372    query = Iq('set', to=jid)
373    pubsub = query.addChild('pubsub', namespace=Namespace.PUBSUB)
374    publish = pubsub.addChild('publish', {'node': node})
375    attrs = {}
376    if id_ is not None:
377        attrs = {'id': id_}
378    publish.addChild('item', attrs, [item])
379    if options:
380        publish = pubsub.addChild('publish-options')
381        publish.addChild(node=_make_publish_options(options))
382    return query
383
384
385def _make_publish_options(options):
386    data = Node(Namespace.DATA + ' x', attrs={'type': 'submit'})
387    field = data.addChild('field', attrs={'var': 'FORM_TYPE', 'type': 'hidden'})
388    field.setTagData('value', Namespace.PUBSUB_PUBLISH_OPTIONS)
389
390    for var, value in options.items():
391        field = data.addChild('field', attrs={'var': var})
392        field.setTagData('value', value)
393    return data
394
395
396def _make_retract_request(node, id_, jid, notify):
397    query = Iq('set', to=jid)
398    pubsub = query.addChild('pubsub', namespace=Namespace.PUBSUB)
399    attrs = {'node': node}
400    if notify:
401        attrs['notify'] = 'true'
402    retract = pubsub.addChild('retract', attrs=attrs)
403    retract.addChild('item', {'id': id_})
404    return query
405
406
407def _make_delete_request(node, jid):
408    query = Iq('set', to=jid)
409    pubsub = query.addChild('pubsub', namespace=Namespace.PUBSUB_OWNER)
410
411    pubsub.addChild('delete', attrs={'node': node})
412    return query
413
414
415def _make_node_configuration(node, form, jid):
416    query = Iq('set', to=jid)
417    pubsub = query.addChild('pubsub', namespace=Namespace.PUBSUB_OWNER)
418    configure = pubsub.addChild('configure', {'node': node})
419    form.setAttr('type', 'submit')
420    configure.addChild(node=form)
421    return query
422
423
424def _make_node_configuration_request(node, jid):
425    query = Iq('get', to=jid)
426    pubsub = query.addChild('pubsub', namespace=Namespace.PUBSUB_OWNER)
427    pubsub.addChild('configure', {'node': node})
428    return query
429
430
431def _apply_options(form, options):
432    for var, value in options.items():
433        try:
434            field = form[var]
435        except KeyError:
436            pass
437        else:
438            field.value = value
439
440
441PubSubNodeConfigurationResult = namedtuple('PubSubConfigResult',
442                                           'jid node form')
443
444PubSubConfigResult = namedtuple('PubSubConfigResult',
445                                'jid node form')
446
447PubSubPublishResult = namedtuple('PubSubPublishResult',
448                                 'jid node id')
449