# HG changeset patch # User Goffi # Date 1565949602 -7200 # Node ID c56a728412f1b252bd38c83cd539a558d3159305 # Parent 105a0772eedd0ca260119f957f594a34dae78eb7 file organisation + setup refactoring: - `/src` has been renamed to `/sat_pubsub`, this is the recommended naming convention - revamped `setup.py` on the basis of SàT's `setup.py` - added a `VERSION` which is the unique place where version number will now be set - use same trick as in SàT to specify dev version (`D` at the end) - use setuptools_scm to retrieve Mercurial hash when in dev version diff -r 105a0772eedd -r c56a728412f1 MANIFEST.in --- a/MANIFEST.in Wed Jul 24 19:26:43 2019 +0200 +++ b/MANIFEST.in Fri Aug 16 12:00:02 2019 +0200 @@ -1,5 +1,8 @@ -include INSTALL -include COPYING -include CHANGELOG -include db/* +include MANIFEST.in sat_pubsub/VERSION +global-include *.py +global-include CHANGELOG COPYING* INSTALL README* +global-include Makefile *.rst *.bat +graft doc +graft db graft twisted +global-exclude *.un~ diff -r 105a0772eedd -r c56a728412f1 README --- a/README Wed Jul 24 19:26:43 2019 +0200 +++ b/README Fri Aug 16 12:00:02 2019 +0200 @@ -1,11 +1,11 @@ -SàT PubSub component v0.3.0a1 +SàT PubSub This program is heavily based on Idavoll (0.9.1), which was written by Ralph Meijer Copyright (c) 2012-2019 Jérôme Poisson Copyright (c) 2014-2016 Adrien Cossa Copyright (c) 2003-2011 Ralph Meijer -SàT PubSub is a PubSub component service for XMPP +SàT PubSub is a PubSub/PEP component for XMPP ** LICENSE ** @@ -26,7 +26,7 @@ ** ABOUT ** -SàT PubSub is a XMPP PubSub service component (XEP-0060) +SàT PubSub is a XMPP PubSub/PEP component (XEP-0060) It's based on Ralph Meijer's Idavoll, and provides special features necessary for the « Salut à Toi » project (https://salut-a-toi.org), but it can also be used for any other XMPP project. The use of a standard external component allow to use this features with most XMPP servers. One of the main addition is fine access tuning for PubSub, which allow the publication of items for only some groups, even if the entire node is open. The protocol is explained on https://www.goffi.org/post/2012/06/24/Fine-access-tuning-for-PubSub for the moment, and a protoxep should be proposed to the XSF in the future... diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/VERSION --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/VERSION Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,1 @@ +0.3.0D diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/__init__.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,66 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2019 Jérôme Poisson +# Copyright (c) 2013-2016 Adrien Cossa +# Copyright (c) 2003-2011 Ralph Meijer +# +# +# 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 . +# -- +# +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. +# +# -- +# +# Here is a copy of the original license: +# +# Copyright (c) 2003-2011 Ralph Meijer +# +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: +# +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +""" +SàT PubSub, a generic XMPP publish-subscribe service. +""" + +import os.path + +version_file = os.path.join(os.path.dirname(__file__), "VERSION") +with open(version_file) as f: + __version__ = f.read().strip() + + +# TODO: remove this when changes are merged in Wokkel +from sat_tmp.wokkel import install +install() diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/backend.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/backend.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,1792 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- +# +# Copyright (c) 2012-2019 Jérôme Poisson +# Copyright (c) 2013-2016 Adrien Cossa +# Copyright (c) 2003-2011 Ralph Meijer + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +""" +Generic publish-subscribe backend. + +This module implements a generic publish-subscribe backend service with +business logic as per +U{XEP-0060} that interacts with +a given storage facility. It also provides an adapter from the XMPP +publish-subscribe protocol. +""" + +import copy +import uuid + +from zope.interface import implements + +from twisted.application import service +from twisted.python import components, log +from twisted.internet import defer, reactor +from twisted.words.protocols.jabber.error import StanzaError +# from twisted.words.protocols.jabber.jid import JID, InvalidFormat +from twisted.words.xish import domish, utility + +from wokkel import disco +from wokkel import data_form +from wokkel import rsm +from wokkel import iwokkel +from wokkel import pubsub +from wokkel.subprotocols import XMPPHandler + +from sat_pubsub import error +from sat_pubsub import iidavoll +from sat_pubsub import const +from sat_pubsub import container + + +def _getAffiliation(node, entity): + d = node.getAffiliation(entity) + d.addCallback(lambda affiliation: (node, affiliation)) + return d + + +def elementCopy(element): + """Make a copy of a domish.Element + + The copy will have its own children list, so other elements + can be added as direct children without modifying orignal one. + Children are not deeply copied, so if an element is added to a child or grandchild, + it will also affect original element. + @param element(domish.Element): Element to clone + """ + new_elt = domish.Element( + (element.uri, element.name), + defaultUri = element.defaultUri, + attribs = element.attributes, + localPrefixes = element.localPrefixes) + new_elt.parent = element.parent + new_elt.children = element.children[:] + return new_elt + + +def itemDataCopy(item_data): + """Make a copy of an item_data + + deep copy every element of the tuple but item + do an elementCopy of item_data.item + @param item_data(ItemData): item data to copy + @return (ItemData): copied data + """ + return container.ItemData(*[elementCopy(item_data.item)] + + [copy.deepcopy(d) for d in item_data[1:]]) + + +class BackendService(service.Service, utility.EventDispatcher): + """ + Generic publish-subscribe backend service. + + @cvar nodeOptions: Node configuration form as a mapping from the field + name to a dictionary that holds the field's type, label + and possible options to choose from. + @type nodeOptions: C{dict}. + @cvar defaultConfig: The default node configuration. + """ + + implements(iidavoll.IBackendService) + + nodeOptions = { + const.OPT_PERSIST_ITEMS: + {"type": "boolean", + "label": "Persist items to storage"}, + const.OPT_DELIVER_PAYLOADS: + {"type": "boolean", + "label": "Deliver payloads with event notifications"}, + const.OPT_SEND_LAST_PUBLISHED_ITEM: + {"type": "list-single", + "label": "When to send the last published item", + "options": { + "never": "Never", + "on_sub": "When a new subscription is processed"} + }, + const.OPT_ACCESS_MODEL: + {"type": "list-single", + "label": "Who can subscribe to this node", + "options": { + const.VAL_AMODEL_OPEN: "Public node", + const.VAL_AMODEL_PRESENCE: "Node restricted to entites subscribed to owner presence", + const.VAL_AMODEL_PUBLISHER_ROSTER: "Node restricted to some groups of publisher's roster", + const.VAL_AMODEL_WHITELIST: "Node restricted to some jids", + } + }, + const.OPT_ROSTER_GROUPS_ALLOWED: + {"type": "list-multi", + "label": "Groups of the roster allowed to access the node", + }, + const.OPT_PUBLISH_MODEL: + {"type": "list-single", + "label": "Who can publish to this node", + "options": { + const.VAL_PMODEL_OPEN: "Everybody can publish", + const.VAL_PMODEL_PUBLISHERS: "Only owner and publishers can publish", + const.VAL_PMODEL_SUBSCRIBERS: "Everybody which subscribed to the node", + } + }, + const.OPT_SERIAL_IDS: + {"type": "boolean", + "label": "Use serial ids"}, + const.OPT_CONSISTENT_PUBLISHER: + {"type": "boolean", + "label": "Keep publisher on update"}, + } + + subscriptionOptions = { + "pubsub#subscription_type": + {"type": "list-single", + "options": { + "items": "Receive notification of new items only", + "nodes": "Receive notification of new nodes only"} + }, + "pubsub#subscription_depth": + {"type": "list-single", + "options": { + "1": "Receive notification from direct child nodes only", + "all": "Receive notification from all descendent nodes"} + }, + } + + def __init__(self, storage, config): + utility.EventDispatcher.__init__(self) + self.storage = storage + self._callbackList = [] + self.config = config + self.admins = config[u'admins_jids_list'] + + def isAdmin(self, entity_jid): + """Return True if an entity is an administrator""" + return entity_jid.userhostJID() in self.admins + + def supportsPublishOptions(self): + return True + def supportsPublisherAffiliation(self): + return True + + def supportsGroupBlog(self): + return True + + def supportsOutcastAffiliation(self): + return True + + def supportsPersistentItems(self): + return True + + def supportsPublishModel(self): + return True + + def getNodeType(self, nodeIdentifier, pep, recipient=None): + # FIXME: manage pep and recipient + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(lambda node: node.getType()) + return d + + def _getNodesIds(self, subscribed, pep, recipient): + # TODO: filter whitelist nodes + # TODO: handle publisher-roster (should probably be renamed to owner-roster for nodes) + if not subscribed: + allowed_accesses = {'open', 'whitelist'} + else: + allowed_accesses = {'open', 'presence', 'whitelist'} + return self.storage.getNodeIds(pep, recipient, allowed_accesses) + + def getNodes(self, requestor, pep, recipient): + if pep: + d = self.privilege.isSubscribedFrom(requestor, recipient) + d.addCallback(self._getNodesIds, pep, recipient) + return d + return self.storage.getNodeIds(pep, recipient) + + def getNodeMetaData(self, nodeIdentifier, pep, recipient=None): + # FIXME: manage pep and recipient + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(lambda node: node.getMetaData()) + d.addCallback(self._makeMetaData) + return d + + def _makeMetaData(self, metaData): + options = [] + for key, value in metaData.iteritems(): + if key in self.nodeOptions: + option = {"var": key} + option.update(self.nodeOptions[key]) + option["value"] = value + options.append(option) + + return options + + def _checkAuth(self, node, requestor): + """ Check authorisation of publishing in node for requestor """ + + def check(affiliation): + d = defer.succeed((affiliation, node)) + configuration = node.getConfiguration() + publish_model = configuration[const.OPT_PUBLISH_MODEL] + if publish_model == const.VAL_PMODEL_PUBLISHERS: + if affiliation not in ['owner', 'publisher']: + raise error.Forbidden() + elif publish_model == const.VAL_PMODEL_SUBSCRIBERS: + if affiliation not in ['owner', 'publisher']: + # we are in subscribers publish model, we must check that + # the requestor is a subscriber to allow him to publish + + def checkSubscription(subscribed): + if not subscribed: + raise error.Forbidden() + return (affiliation, node) + + d.addCallback(lambda ignore: node.isSubscribed(requestor)) + d.addCallback(checkSubscription) + elif publish_model != const.VAL_PMODEL_OPEN: + raise ValueError('Unexpected value') # publish_model must be publishers (default), subscribers or open. + + return d + + d = node.getAffiliation(requestor) + d.addCallback(check) + return d + + def parseItemConfig(self, item): + """Get and remove item configuration information + + @param item (domish.Element): item to parse + @return (tuple[unicode, dict)): (access_model, item_config) + """ + item_config = None + access_model = const.VAL_AMODEL_DEFAULT + for idx, elt in enumerate(item.elements()): + if elt.uri != data_form.NS_X_DATA or elt.name != 'x': + continue + form = data_form.Form.fromElement(elt) + if form.formNamespace == const.NS_ITEM_CONFIG: + item_config = form + del item.children[idx] #we need to remove the config from item + break + + if item_config: + access_model = item_config.get(const.OPT_ACCESS_MODEL, const.VAL_AMODEL_DEFAULT) + return (access_model, item_config) + + def parseCategories(self, item_elt): + """Check if item contain an atom entry, and parse categories if possible + + @param item_elt (domish.Element): item to parse + @return (list): list of found categories + """ + categories = [] + try: + entry_elt = item_elt.elements(const.NS_ATOM, "entry").next() + except StopIteration: + return categories + + for category_elt in entry_elt.elements(const.NS_ATOM, 'category'): + category = category_elt.getAttribute('term') + if category: + categories.append(category) + + return categories + + def enforceSchema(self, item_elt, schema, affiliation): + """modifify item according to element, or refuse publishing + + @param item_elt(domish.Element): item to check/modify + @param schema(domish.Eement): schema to enfore + @param affiliation(unicode): affiliation of the publisher + """ + try: + x_elt = next(item_elt.elements(data_form.NS_X_DATA, 'x')) + item_form = data_form.Form.fromElement(x_elt) + except (StopIteration, data_form.Error): + raise pubsub.BadRequest(text="node has a schema but item has no form") + else: + item_elt.children.remove(x_elt) + + schema_form = data_form.Form.fromElement(schema) + + # we enforce restrictions + for field_elt in schema.elements(data_form.NS_X_DATA, 'field'): + var = field_elt['var'] + for restrict_elt in field_elt.elements(const.NS_SCHEMA_RESTRICT, 'restrict'): + write_restriction = restrict_elt.attributes.get('write') + if write_restriction is not None: + if write_restriction == 'owner': + if affiliation != 'owner': + # write is not allowed on this field, we use default value + # we can safely use Field from schema_form because + # we have created this instance only for this method + try: + item_form.removeField(item_form.fields[var]) + except KeyError: + pass + item_form.addField(schema_form.fields[var]) + else: + raise StanzaError('feature-not-implemented', text='unknown write restriction {}'.format(write_restriction)) + + # we now remove every field which is not in data schema + to_remove = set() + for item_var, item_field in item_form.fields.iteritems(): + if item_var not in schema_form.fields: + to_remove.add(item_field) + + for field in to_remove: + item_form.removeField(field) + item_elt.addChild(item_form.toElement()) + + def _checkOverwrite(self, node, itemIdentifiers, publisher): + """Check that publisher can overwrite items + + current publisher must correspond to each item publisher + """ + def doCheck(item_pub_map): + for item_publisher in item_pub_map.itervalues(): + if item_publisher.userhost() != publisher.userhost(): + raise error.ItemForbidden() + + d = node.getItemsPublishers(itemIdentifiers) + d.addCallback(doCheck) + return d + + def publish(self, nodeIdentifier, items, requestor, pep, recipient): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(self._checkAuth, requestor) + #FIXME: owner and publisher are not necessarly the same. So far we use only owner to get roster. + #FIXME: in addition, there can be several owners: that is not managed yet + d.addCallback(self._doPublish, items, requestor, pep, recipient) + return d + + @defer.inlineCallbacks + def _doPublish(self, result, items, requestor, pep, recipient): + affiliation, node = result + if node.nodeType == 'collection': + raise error.NoPublishing() + + configuration = node.getConfiguration() + persistItems = configuration[const.OPT_PERSIST_ITEMS] + deliverPayloads = configuration[const.OPT_DELIVER_PAYLOADS] + + if items and not persistItems and not deliverPayloads: + raise error.ItemForbidden() + elif not items and (persistItems or deliverPayloads): + raise error.ItemRequired() + + items_data = [] + check_overwrite = False + ret_payload = None # payload returned, None or domish.Element + for item in items: + # we enforce publisher (cf XEP-0060 §7.1.2.3) + item['publisher'] = requestor.full() + if persistItems or deliverPayloads: + item.uri = None + item.defaultUri = None + if not item.getAttribute("id"): + item["id"] = yield node.getNextId() + new_item = True + if ret_payload is None: + ret_pubsub_elt = domish.Element((pubsub.NS_PUBSUB, u'pubsub')) + ret_publish_elt = ret_pubsub_elt.addElement(u'publish') + ret_publish_elt[u'node'] = node.nodeIdentifier + ret_payload = ret_pubsub_elt + ret_publish_elt = ret_payload.publish + ret_item_elt = ret_publish_elt.addElement(u'item') + ret_item_elt["id"] = item[u"id"] + else: + check_overwrite = True + new_item = False + access_model, item_config = self.parseItemConfig(item) + categories = self.parseCategories(item) + schema = node.getSchema() + if schema is not None: + self.enforceSchema(item, schema, affiliation) + items_data.append(container.ItemData(item, access_model, item_config, categories, new=new_item)) + + if persistItems: + + if check_overwrite: + itemIdentifiers = [item['id'] for item in items + if item.getAttribute('id')] + + if affiliation == 'owner' or self.isAdmin(requestor): + if configuration[const.OPT_CONSISTENT_PUBLISHER]: + pub_map = yield node.getItemsPublishers(itemIdentifiers) + publishers = set(pub_map.values()) + if len(publishers) != 1: + # TODO: handle multiple items publishing (from several + # publishers) + raise error.NoPublishing( + u"consistent_publisher is currently only possible when " + u"publishing items from a single publisher. Try to " + u"publish one item at a time") + # we replace requestor and new payload's publisher by original + # item publisher to keep publisher consistent + requestor = publishers.pop() + for item in items: + item['publisher'] = requestor.full() + else: + # we don't want a publisher to overwrite the item + # of an other publisher + yield self._checkOverwrite(node, itemIdentifiers, requestor) + + # TODO: check conflict and recalculate max id if serial_ids is set + yield node.storeItems(items_data, requestor) + + yield self._doNotify(node, items_data, deliverPayloads, pep, recipient) + defer.returnValue(ret_payload) + + def _doNotify(self, node, items_data, deliverPayloads, pep, recipient): + if items_data and not deliverPayloads: + for item_data in items_data: + item_data.item.children = [] + self.dispatch({'items_data': items_data, 'node': node, 'pep': pep, 'recipient': recipient}, + '//event/pubsub/notify') + + def getNotifications(self, node, items_data): + """Build a list of subscriber to the node + + subscribers will be associated with subscribed items, + and subscription type. + """ + + def toNotifications(subscriptions, items_data): + subsBySubscriber = {} + for subscription in subscriptions: + if subscription.options.get('pubsub#subscription_type', + 'items') == 'items': + subs = subsBySubscriber.setdefault(subscription.subscriber, + set()) + subs.add(subscription) + + notifications = [(subscriber, subscriptions_, items_data) + for subscriber, subscriptions_ + in subsBySubscriber.iteritems()] + + return notifications + + def rootNotFound(failure): + failure.trap(error.NodeNotFound) + return [] + + d1 = node.getSubscriptions('subscribed') + # FIXME: must add root node subscriptions ? + # d2 = self.storage.getNode('', False) # FIXME: to check + # d2.addCallback(lambda node: node.getSubscriptions('subscribed')) + # d2.addErrback(rootNotFound) + # d = defer.gatherResults([d1, d2]) + # d.addCallback(lambda result: result[0] + result[1]) + d1.addCallback(toNotifications, items_data) + return d1 + + def registerPublishNotifier(self, observerfn, *args, **kwargs): + self.addObserver('//event/pubsub/notify', observerfn, *args, **kwargs) + + def registerRetractNotifier(self, observerfn, *args, **kwargs): + self.addObserver('//event/pubsub/retract', observerfn, *args, **kwargs) + + def subscribe(self, nodeIdentifier, subscriber, requestor, pep, recipient): + subscriberEntity = subscriber.userhostJID() + if subscriberEntity != requestor.userhostJID(): + return defer.fail(error.Forbidden()) + + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, subscriberEntity) + d.addCallback(self._doSubscribe, subscriber, pep, recipient) + return d + + def _doSubscribe(self, result, subscriber, pep, recipient): + node, affiliation = result + + if affiliation == 'outcast': + raise error.Forbidden() + + access_model = node.getAccessModel() + + if access_model == const.VAL_AMODEL_OPEN: + d = defer.succeed(None) + elif access_model == const.VAL_AMODEL_PRESENCE: + d = self.checkPresenceSubscription(node, subscriber) + elif access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: + d = self.checkRosterGroups(node, subscriber) + elif access_model == const.VAL_AMODEL_WHITELIST: + d = self.checkNodeAffiliations(node, subscriber) + else: + raise NotImplementedError + + def trapExists(failure): + failure.trap(error.SubscriptionExists) + return False + + def cb(sendLast): + d = node.getSubscription(subscriber) + if sendLast: + d.addCallback(self._sendLastPublished, node, pep, recipient) + return d + + d.addCallback(lambda _: node.addSubscription(subscriber, 'subscribed', {})) + d.addCallbacks(lambda _: True, trapExists) + d.addCallback(cb) + + return d + + def _sendLastPublished(self, subscription, node, pep, recipient): + + def notifyItem(items_data): + if items_data: + reactor.callLater(0, self.dispatch, + {'items_data': items_data, + 'node': node, + 'pep': pep, + 'recipient': recipient, + 'subscription': subscription, + }, + '//event/pubsub/notify') + + config = node.getConfiguration() + sendLastPublished = config.get('pubsub#send_last_published_item', + 'never') + if sendLastPublished == 'on_sub' and node.nodeType == 'leaf': + entity = subscription.subscriber.userhostJID() + d = self.getItemsData(node.nodeIdentifier, entity, recipient, maxItems=1, ext_data={'pep': pep}) + d.addCallback(notifyItem) + d.addErrback(log.err) + + return subscription + + def unsubscribe(self, nodeIdentifier, subscriber, requestor, pep, recipient): + if subscriber.userhostJID() != requestor.userhostJID(): + return defer.fail(error.Forbidden()) + + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(lambda node: node.removeSubscription(subscriber)) + return d + + def getSubscriptions(self, requestor, nodeIdentifier, pep, recipient): + """retrieve subscriptions of an entity + + @param requestor(jid.JID): entity who want to check subscriptions + @param nodeIdentifier(unicode, None): identifier of the node + node to get all subscriptions of a service + @param pep(bool): True if it's a PEP request + @param recipient(jid.JID, None): recipient of the PEP request + """ + return self.storage.getSubscriptions(requestor, nodeIdentifier, pep, recipient) + + def supportsAutoCreate(self): + return True + + def supportsCreatorCheck(self): + return True + + def supportsInstantNodes(self): + return True + + def createNode(self, nodeIdentifier, requestor, options = None, pep=False, recipient=None): + if not nodeIdentifier: + nodeIdentifier = 'generic/%s' % uuid.uuid4() + + if not options: + options = {} + + # if self.supportsCreatorCheck(): + # groupblog = nodeIdentifier.startswith(const.NS_GROUPBLOG_PREFIX) + # try: + # nodeIdentifierJID = JID(nodeIdentifier[len(const.NS_GROUPBLOG_PREFIX):] if groupblog else nodeIdentifier) + # except InvalidFormat: + # is_user_jid = False + # else: + # is_user_jid = bool(nodeIdentifierJID.user) + + # if is_user_jid and nodeIdentifierJID.userhostJID() != requestor.userhostJID(): + # #we have an user jid node, but not created by the owner of this jid + # print "Wrong creator" + # raise error.Forbidden() + + nodeType = 'leaf' + config = self.storage.getDefaultConfiguration(nodeType) + config['pubsub#node_type'] = nodeType + config.update(options) + + # TODO: handle schema on creation + d = self.storage.createNode(nodeIdentifier, requestor, config, None, pep, recipient) + d.addCallback(lambda _: nodeIdentifier) + return d + + def getDefaultConfiguration(self, nodeType): + d = defer.succeed(self.storage.getDefaultConfiguration(nodeType)) + return d + + def getNodeConfiguration(self, nodeIdentifier, pep, recipient): + if not nodeIdentifier: + return defer.fail(error.NoRootNode()) + + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(lambda node: node.getConfiguration()) + + return d + + def setNodeConfiguration(self, nodeIdentifier, options, requestor, pep, recipient): + if not nodeIdentifier: + return defer.fail(error.NoRootNode()) + + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doSetNodeConfiguration, requestor, options) + return d + + def _doSetNodeConfiguration(self, result, requestor, options): + node, affiliation = result + + if affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + + return node.setConfiguration(options) + + def getNodeSchema(self, nodeIdentifier, pep, recipient): + if not nodeIdentifier: + return defer.fail(error.NoRootNode()) + + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(lambda node: node.getSchema()) + + return d + + def setNodeSchema(self, nodeIdentifier, schema, requestor, pep, recipient): + """set or remove Schema of a node + + @param nodeIdentifier(unicode): identifier of the pubusb node + @param schema(domish.Element, None): schema to set + None to remove schema + @param requestor(jid.JID): entity doing the request + @param pep(bool): True if it's a PEP request + @param recipient(jid.JID, None): recipient of the PEP request + """ + if not nodeIdentifier: + return defer.fail(error.NoRootNode()) + + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doSetNodeSchema, requestor, schema) + return d + + def _doSetNodeSchema(self, result, requestor, schema): + node, affiliation = result + + if affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + + return node.setSchema(schema) + + def getAffiliations(self, entity, nodeIdentifier, pep, recipient): + return self.storage.getAffiliations(entity, nodeIdentifier, pep, recipient) + + def getAffiliationsOwner(self, nodeIdentifier, requestor, pep, recipient): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doGetAffiliationsOwner, requestor) + return d + + def _doGetAffiliationsOwner(self, result, requestor): + node, affiliation = result + + if affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + return node.getAffiliations() + + def setAffiliationsOwner(self, nodeIdentifier, requestor, affiliations, pep, recipient): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doSetAffiliationsOwner, requestor, affiliations) + return d + + def _doSetAffiliationsOwner(self, result, requestor, affiliations): + # Check that requestor is allowed to set affiliations, and delete entities + # with "none" affiliation + + # TODO: return error with failed affiliations in case of failure + node, requestor_affiliation = result + + if requestor_affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + + # we don't allow requestor to change its own affiliation + requestor_bare = requestor.userhostJID() + if requestor_bare in affiliations and affiliations[requestor_bare] != 'owner': + # FIXME: it may be interesting to allow the owner to ask for ownership removal + # if at least one other entity is owner for this node + raise error.Forbidden("You can't change your own affiliation") + + to_delete = [jid_ for jid_, affiliation in affiliations.iteritems() if affiliation == 'none'] + for jid_ in to_delete: + del affiliations[jid_] + + if to_delete: + d = node.deleteAffiliations(to_delete) + if affiliations: + d.addCallback(lambda dummy: node.setAffiliations(affiliations)) + else: + d = node.setAffiliations(affiliations) + + return d + + def getSubscriptionsOwner(self, nodeIdentifier, requestor, pep, recipient): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doGetSubscriptionsOwner, requestor) + return d + + def _doGetSubscriptionsOwner(self, result, requestor): + node, affiliation = result + + if affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + return node.getSubscriptions() + + def setSubscriptionsOwner(self, nodeIdentifier, requestor, subscriptions, pep, recipient): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doSetSubscriptionsOwner, requestor, subscriptions) + return d + + def unwrapFirstError(self, failure): + failure.trap(defer.FirstError) + return failure.value.subFailure + + def _doSetSubscriptionsOwner(self, result, requestor, subscriptions): + # Check that requestor is allowed to set subscriptions, and delete entities + # with "none" subscription + + # TODO: return error with failed subscriptions in case of failure + node, requestor_affiliation = result + + if requestor_affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + + d_list = [] + + for subscription in subscriptions.copy(): + if subscription.state == 'none': + subscriptions.remove(subscription) + d_list.append(node.removeSubscription(subscription.subscriber)) + + if subscriptions: + d_list.append(node.setSubscriptions(subscriptions)) + + d = defer.gatherResults(d_list, consumeErrors=True) + d.addCallback(lambda _: None) + d.addErrback(self.unwrapFirstError) + return d + + def filterItemsWithSchema(self, items_data, schema, owner): + """check schema restriction and remove fields/items if they don't comply + + @param items_data(list[ItemData]): items to filter + items in this list will be modified + @param schema(domish.Element): node schema + @param owner(bool): True is requestor is a owner of the node + """ + fields_to_remove = set() + for field_elt in schema.elements(data_form.NS_X_DATA, 'field'): + for restrict_elt in field_elt.elements(const.NS_SCHEMA_RESTRICT, 'restrict'): + read_restriction = restrict_elt.attributes.get('read') + if read_restriction is not None: + if read_restriction == 'owner': + if not owner: + fields_to_remove.add(field_elt['var']) + else: + raise StanzaError('feature-not-implemented', text='unknown read restriction {}'.format(read_restriction)) + items_to_remove = [] + for idx, item_data in enumerate(items_data): + item_elt = item_data.item + try: + x_elt = next(item_elt.elements(data_form.NS_X_DATA, 'x')) + except StopIteration: + log.msg("WARNING, item {id} has a schema but no form, ignoring it") + items_to_remove.append(item_data) + continue + form = data_form.Form.fromElement(x_elt) + # we remove fields which are not visible for this user + for field in fields_to_remove: + try: + form.removeField(form.fields[field]) + except KeyError: + continue + item_elt.children.remove(x_elt) + item_elt.addChild(form.toElement()) + + for item_data in items_to_remove: + items_data.remove(item_data) + + def checkPresenceSubscription(self, node, requestor): + """check if requestor has presence subscription from node owner + + @param node(Node): node to check + @param requestor(jid.JID): entity who want to access node + """ + def gotRoster(roster): + if roster is None: + raise error.Forbidden() + + if requestor not in roster: + raise error.Forbidden() + + if not roster[requestor].subscriptionFrom: + raise error.Forbidden() + + d = self.getOwnerRoster(node) + d.addCallback(gotRoster) + return d + + @defer.inlineCallbacks + def checkRosterGroups(self, node, requestor): + """check if requestor is in allowed groups of a node + + @param node(Node): node to check + @param requestor(jid.JID): entity who want to access node + """ + roster = yield self.getOwnerRoster(node) + + if roster is None: + raise error.Forbidden() + + if requestor not in roster: + raise error.Forbidden() + + authorized_groups = yield node.getAuthorizedGroups() + + if not roster[requestor].groups.intersection(authorized_groups): + # requestor is in roster but not in one of the allowed groups + raise error.Forbidden() + + def checkNodeAffiliations(self, node, requestor): + """check if requestor is in white list of a node + + @param node(Node): node to check + @param requestor(jid.JID): entity who want to access node + """ + def gotAffiliations(affiliations): + try: + affiliation = affiliations[requestor.userhostJID()] + except KeyError: + raise error.Forbidden() + else: + if affiliation not in ('owner', 'publisher', 'member'): + raise error.Forbidden() + + d = node.getAffiliations() + d.addCallback(gotAffiliations) + return d + + @defer.inlineCallbacks + def checkNodeAccess(self, node, requestor): + """check if a requestor can access data of a node + + @param node(Node): node to check + @param requestor(jid.JID): entity who want to access node + @return (tuple): permissions data with: + - owner(bool): True if requestor is owner of the node + - roster(None, ): roster of the requestor + None if not needed/available + - access_model(str): access model of the node + @raise error.Forbidden: access is not granted + @raise error.NotLeafNodeError: this node is not a leaf + """ + node, affiliation = yield _getAffiliation(node, requestor) + + if not iidavoll.ILeafNode.providedBy(node): + raise error.NotLeafNodeError() + + if affiliation == 'outcast': + raise error.Forbidden() + + # node access check + owner = affiliation == 'owner' + access_model = node.getAccessModel() + roster = None + + if access_model == const.VAL_AMODEL_OPEN or owner: + pass + elif access_model == const.VAL_AMODEL_PRESENCE: + yield self.checkPresenceSubscription(node, requestor) + elif access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: + # FIXME: for node, access should be renamed owner-roster, not publisher + yield self.checkRosterGroups(node, requestor) + elif access_model == const.VAL_AMODEL_WHITELIST: + yield self.checkNodeAffiliations(node, requestor) + else: + raise Exception(u"Unknown access_model") + + defer.returnValue((affiliation, owner, roster, access_model)) + + @defer.inlineCallbacks + def getItemsIds(self, nodeIdentifier, requestor, authorized_groups, unrestricted, maxItems=None, ext_data=None, pep=False, recipient=None): + # FIXME: items access model are not checked + # TODO: check items access model + node = yield self.storage.getNode(nodeIdentifier, pep, recipient) + affiliation, owner, roster, access_model = yield self.checkNodeAccess(node, requestor) + ids = yield node.getItemsIds(authorized_groups, + unrestricted, + maxItems, + ext_data) + defer.returnValue(ids) + + def getItems(self, nodeIdentifier, requestor, recipient, maxItems=None, + itemIdentifiers=None, ext_data=None): + d = self.getItemsData(nodeIdentifier, requestor, recipient, maxItems, itemIdentifiers, ext_data) + d.addCallback(lambda items_data: [item_data.item for item_data in items_data]) + return d + + @defer.inlineCallbacks + def getOwnerRoster(self, node, owners=None): + # FIXME: roster of publisher, not owner, must be used + if owners is None: + owners = yield node.getOwners() + + if len(owners) != 1: + log.msg('publisher-roster access is not allowed with more than 1 owner') + return + + owner_jid = owners[0] + + try: + roster = yield self.privilege.getRoster(owner_jid) + except Exception as e: + log.msg("Error while getting roster of {owner_jid}: {msg}".format( + owner_jid = owner_jid.full(), + msg = e)) + return + defer.returnValue(roster) + + @defer.inlineCallbacks + def getItemsData(self, nodeIdentifier, requestor, recipient, maxItems=None, + itemIdentifiers=None, ext_data=None): + """like getItems but return the whole ItemData""" + if maxItems == 0: + log.msg("WARNING: maxItems=0 on items retrieval") + defer.returnValue([]) + + if ext_data is None: + ext_data = {} + node = yield self.storage.getNode(nodeIdentifier, ext_data.get('pep', False), recipient) + try: + affiliation, owner, roster, access_model = yield self.checkNodeAccess(node, requestor) + except error.NotLeafNodeError: + defer.returnValue([]) + + # at this point node access is checked + + if owner: + # requestor_groups is only used in restricted access + requestor_groups = None + else: + if roster is None: + # FIXME: publisher roster should be used, not owner + roster = yield self.getOwnerRoster(node) + if roster is None: + roster = {} + roster_item = roster.get(requestor.userhostJID()) + requestor_groups = tuple(roster_item.groups) if roster_item else tuple() + + if itemIdentifiers: + items_data = yield node.getItemsById(requestor_groups, owner, itemIdentifiers) + else: + items_data = yield node.getItems(requestor_groups, owner, maxItems, ext_data) + + if owner: + # Add item config data form to items with roster access model + for item_data in items_data: + if item_data.access_model == const.VAL_AMODEL_OPEN: + pass + elif item_data.access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: + form = data_form.Form('submit', formNamespace=const.NS_ITEM_CONFIG) + access = data_form.Field(None, const.OPT_ACCESS_MODEL, value=const.VAL_AMODEL_PUBLISHER_ROSTER) + allowed = data_form.Field(None, const.OPT_ROSTER_GROUPS_ALLOWED, values=item_data.config[const.OPT_ROSTER_GROUPS_ALLOWED]) + form.addField(access) + form.addField(allowed) + item_data.item.addChild(form.toElement()) + elif access_model == const.VAL_AMODEL_WHITELIST: + #FIXME + raise NotImplementedError + else: + raise error.BadAccessTypeError(access_model) + + schema = node.getSchema() + if schema is not None: + self.filterItemsWithSchema(items_data, schema, owner) + + yield self._items_rsm(items_data, node, requestor_groups, owner, itemIdentifiers, ext_data) + defer.returnValue(items_data) + + def _setCount(self, value, response): + response.count = value + + def _setIndex(self, value, response, adjust): + """Set index in RSM response + + @param value(int): value of the reference index (i.e. before or after item) + @param response(RSMResponse): response instance to fill + @param adjust(int): adjustement term (i.e. difference between reference index and first item of the result) + """ + response.index = value + adjust + + def _items_rsm(self, items_data, node, authorized_groups, owner, + itemIdentifiers, ext_data): + # FIXME: move this to a separate module + # TODO: Index can be optimized by keeping a cache of the last RSM request + # An other optimisation would be to look for index first and use it as offset + try: + rsm_request = ext_data['rsm'] + except KeyError: + # No RSM in this request, nothing to do + return items_data + + if itemIdentifiers: + log.msg("WARNING, itemIdentifiers used with RSM, ignoring the RSM part") + return items_data + + response = rsm.RSMResponse() + + d_count = node.getItemsCount(authorized_groups, owner, ext_data) + d_count.addCallback(self._setCount, response) + d_list = [d_count] + + if items_data: + response.first = items_data[0].item['id'] + response.last = items_data[-1].item['id'] + + # index handling + if rsm_request.index is not None: + response.index = rsm_request.index + elif rsm_request.before: + # The last page case (before == '') is managed in render method + d_index = node.getItemsIndex(rsm_request.before, authorized_groups, owner, ext_data) + d_index.addCallback(self._setIndex, response, -len(items_data)) + d_list.append(d_index) + elif rsm_request.after is not None: + d_index = node.getItemsIndex(rsm_request.after, authorized_groups, owner, ext_data) + d_index.addCallback(self._setIndex, response, 1) + d_list.append(d_index) + else: + # the first page was requested + response.index = 0 + + def render(result): + if rsm_request.before == '': + # the last page was requested + response.index = response.count - len(items_data) + items_data.append(container.ItemData(response.toElement())) + return items_data + + return defer.DeferredList(d_list).addCallback(render) + + def retractItem(self, nodeIdentifier, itemIdentifiers, requestor, notify, pep, recipient): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doRetract, itemIdentifiers, requestor, notify, pep, recipient) + return d + + def _doRetract(self, result, itemIdentifiers, requestor, notify, pep, recipient): + node, affiliation = result + persistItems = node.getConfiguration()[const.OPT_PERSIST_ITEMS] + + if not persistItems: + raise error.NodeNotPersistent() + + # we need to get the items before removing them, for the notifications + + def removeItems(items_data): + """Remove the items and keep only actually removed ones in items_data""" + d = node.removeItems(itemIdentifiers) + d.addCallback(lambda removed: [item_data for item_data in items_data if item_data.item["id"] in removed]) + return d + + def checkPublishers(publishers_map): + """Called when requestor is neither owner neither publisher of the Node + + We check that requestor is publisher of all the items he wants to retract + and raise error.Forbidden if it is not the case + """ + # TODO: the behaviour should be configurable (per node ?) + if (any((requestor.userhostJID() != publisher.userhostJID() + for publisher in publishers_map.itervalues())) + and not self.isAdmin(requestor) + ): + raise error.Forbidden() + + if affiliation in ['owner', 'publisher']: + # the requestor is owner or publisher of the node + # he can retract what he wants + d = defer.succeed(None) + else: + # the requestor doesn't have right to retract on the whole node + # we check if he is a publisher for all items he wants to retract + # and forbid the retraction else. + d = node.getItemsPublishers(itemIdentifiers) + d.addCallback(checkPublishers) + d.addCallback(lambda dummy: node.getItemsById(None, True, itemIdentifiers)) + d.addCallback(removeItems) + + if notify: + d.addCallback(self._doNotifyRetraction, node, pep, recipient) + return d + + def _doNotifyRetraction(self, items_data, node, pep, recipient): + self.dispatch({'items_data': items_data, + 'node': node, + 'pep': pep, + 'recipient': recipient}, + '//event/pubsub/retract') + + def purgeNode(self, nodeIdentifier, requestor, pep, recipient): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doPurge, requestor) + return d + + def _doPurge(self, result, requestor): + node, affiliation = result + persistItems = node.getConfiguration()[const.OPT_PERSIST_ITEMS] + + if affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + + if not persistItems: + raise error.NodeNotPersistent() + + d = node.purge() + d.addCallback(self._doNotifyPurge, node.nodeIdentifier) + return d + + def _doNotifyPurge(self, result, nodeIdentifier): + self.dispatch(nodeIdentifier, '//event/pubsub/purge') + + def registerPreDelete(self, preDeleteFn): + self._callbackList.append(preDeleteFn) + + def getSubscribers(self, nodeIdentifier, pep, recipient): + def cb(subscriptions): + return [subscription.subscriber for subscription in subscriptions] + + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(lambda node: node.getSubscriptions('subscribed')) + d.addCallback(cb) + return d + + def deleteNode(self, nodeIdentifier, requestor, pep, recipient, redirectURI=None): + d = self.storage.getNode(nodeIdentifier, pep, recipient) + d.addCallback(_getAffiliation, requestor) + d.addCallback(self._doPreDelete, requestor, redirectURI, pep, recipient) + return d + + def _doPreDelete(self, result, requestor, redirectURI, pep, recipient): + node, affiliation = result + + if affiliation != 'owner' and not self.isAdmin(requestor): + raise error.Forbidden() + + data = {'node': node, + 'redirectURI': redirectURI} + + d = defer.DeferredList([cb(data, pep, recipient) + for cb in self._callbackList], + consumeErrors=1) + d.addCallback(self._doDelete, node.nodeDbId) + + def _doDelete(self, result, nodeDbId): + dl = [] + for succeeded, r in result: + if succeeded and r: + dl.extend(r) + + d = self.storage.deleteNodeByDbId(nodeDbId) + d.addCallback(self._doNotifyDelete, dl) + + return d + + def _doNotifyDelete(self, result, dl): + for d in dl: + d.callback(None) + + +class PubSubResourceFromBackend(pubsub.PubSubResource): + """ + Adapts a backend to an xmpp publish-subscribe service. + """ + + features = [ + "config-node", + "create-nodes", + "delete-any", + "delete-nodes", + "item-ids", + "meta-data", + "publish", + "purge-nodes", + "retract-items", + "retrieve-affiliations", + "retrieve-default", + "retrieve-items", + "retrieve-subscriptions", + "subscribe", + ] + + discoIdentity = disco.DiscoIdentity('pubsub', + 'service', + u'Salut à Toi pubsub service') + + pubsubService = None + + _errorMap = { + error.NodeNotFound: ('item-not-found', None, None), + error.NodeExists: ('conflict', None, None), + error.Forbidden: ('forbidden', None, None), + error.NotAuthorized: ('not-authorized', None, None), + error.ItemNotFound: ('item-not-found', None, None), + error.ItemForbidden: ('bad-request', 'item-forbidden', None), + error.ItemRequired: ('bad-request', 'item-required', None), + error.NoInstantNodes: ('not-acceptable', + 'unsupported', + 'instant-nodes'), + error.NotSubscribed: ('unexpected-request', 'not-subscribed', None), + error.InvalidConfigurationOption: ('not-acceptable', None, None), + error.InvalidConfigurationValue: ('not-acceptable', None, None), + error.NodeNotPersistent: ('feature-not-implemented', + 'unsupported', + 'persistent-node'), + error.NoRootNode: ('bad-request', None, None), + error.NoCollections: ('feature-not-implemented', + 'unsupported', + 'collections'), + error.NoPublishing: ('feature-not-implemented', + 'unsupported', + 'publish'), + } + + def __init__(self, backend): + pubsub.PubSubResource.__init__(self) + + self.backend = backend + self.hideNodes = False + + self.backend.registerPublishNotifier(self._notifyPublish) + self.backend.registerRetractNotifier(self._notifyRetract) + self.backend.registerPreDelete(self._preDelete) + + # FIXME: to be removed, it's not useful anymore as PEP is now used + # if self.backend.supportsCreatorCheck(): + # self.features.append("creator-jid-check") #SàT custom feature: Check that a node (which correspond to + # a jid in this server) is created by the right jid + + if self.backend.supportsAutoCreate(): + self.features.append("auto-create") + + if self.backend.supportsPublishOptions(): + self.features.append("publish-options") + + if self.backend.supportsInstantNodes(): + self.features.append("instant-nodes") + + if self.backend.supportsOutcastAffiliation(): + self.features.append("outcast-affiliation") + + if self.backend.supportsPersistentItems(): + self.features.append("persistent-items") + + if self.backend.supportsPublisherAffiliation(): + self.features.append("publisher-affiliation") + + if self.backend.supportsGroupBlog(): + self.features.append("groupblog") + + + # if self.backend.supportsPublishModel(): #XXX: this feature is not really described in XEP-0060, we just can see it in examples + # self.features.append("publish_model") # but it's necessary for microblogging comments (see XEP-0277) + + def getFullItem(self, item_data): + """ Attach item configuration to this item + + Used to give item configuration back to node's owner (and *only* to owner) + """ + # TODO: a test should check that only the owner get the item configuration back + + item, item_config = item_data.item, item_data.config + if item_config: + new_item = elementCopy(item) + new_item.addChild(item_config.toElement()) + return new_item + else: + return item + + @defer.inlineCallbacks + def _notifyPublish(self, data): + items_data = data['items_data'] + node = data['node'] + pep = data['pep'] + recipient = data['recipient'] + + owners, notifications_filtered = yield self._prepareNotify(items_data, node, data.get('subscription'), pep, recipient) + + # we notify the owners + # FIXME: check if this comply with XEP-0060 (option needed ?) + # TODO: item's access model have to be sent back to owner + # TODO: same thing for getItems + + for owner_jid in owners: + notifications_filtered.append( + (owner_jid, + {pubsub.Subscription(node.nodeIdentifier, + owner_jid, + 'subscribed')}, + [self.getFullItem(item_data) for item_data in items_data])) + + if pep: + defer.returnValue(self.backend.privilege.notifyPublish( + recipient, + node.nodeIdentifier, + notifications_filtered)) + + else: + defer.returnValue(self.pubsubService.notifyPublish( + self.serviceJID, + node.nodeIdentifier, + notifications_filtered)) + + def _notifyRetract(self, data): + items_data = data['items_data'] + node = data['node'] + pep = data['pep'] + recipient = data['recipient'] + + def afterPrepare(result): + owners, notifications_filtered = result + #we add the owners + + for owner_jid in owners: + notifications_filtered.append( + (owner_jid, + {pubsub.Subscription(node.nodeIdentifier, + owner_jid, + 'subscribed')}, + [item_data.item for item_data in items_data])) + + if pep: + return self.backend.privilege.notifyRetract( + recipient, + node.nodeIdentifier, + notifications_filtered) + + else: + return self.pubsubService.notifyRetract( + self.serviceJID, + node.nodeIdentifier, + notifications_filtered) + + d = self._prepareNotify(items_data, node, data.get('subscription'), pep, recipient) + d.addCallback(afterPrepare) + return d + + @defer.inlineCallbacks + def _prepareNotify(self, items_data, node, subscription=None, pep=None, recipient=None): + """Do a bunch of permissions check and filter notifications + + The owner is not added to these notifications, + it must be added by the calling method + @param items_data(tuple): must contain: + - item (domish.Element) + - access_model (unicode) + - access_list (dict as returned getItemsById, or item_config) + @param node(LeafNode): node hosting the items + @param subscription(pubsub.Subscription, None): TODO + + @return (tuple): will contain: + - notifications_filtered + - node_owner_jid + - items_data + """ + if subscription is None: + notifications = yield self.backend.getNotifications(node, items_data) + else: + notifications = [(subscription.subscriber, [subscription], items_data)] + + if pep and node.getConfiguration()[const.OPT_ACCESS_MODEL] in ('open', 'presence'): + # for PEP we need to manage automatic subscriptions (cf. XEP-0163 §4) + explicit_subscribers = {subscriber for subscriber, _, _ in notifications} + auto_subscribers = yield self.backend.privilege.getAutoSubscribers(recipient, node.nodeIdentifier, explicit_subscribers) + for sub_jid in auto_subscribers: + sub = pubsub.Subscription(node.nodeIdentifier, sub_jid, 'subscribed') + notifications.append((sub_jid, [sub], items_data)) + + owners = yield node.getOwners() + owner_roster = None + + # now we check access of subscriber for each item, and keep only allowed ones + + #we filter items not allowed for the subscribers + notifications_filtered = [] + schema = node.getSchema() + + for subscriber, subscriptions, items_data in notifications: + subscriber_bare = subscriber.userhostJID() + if subscriber_bare in owners: + # as notification is always sent to owner, + # we ignore owner if he is here + continue + allowed_items = [] #we keep only item which subscriber can access + + if schema is not None: + # we have to copy items_data because different subscribers may receive + # different items (e.g. read restriction in schema) + items_data = [itemDataCopy(item_data) for item_data in items_data] + self.backend.filterItemsWithSchema(items_data, schema, False) + + for item_data in items_data: + item, access_model = item_data.item, item_data.access_model + access_list = item_data.config + if access_model == const.VAL_AMODEL_OPEN: + allowed_items.append(item) + elif access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: + if owner_roster is None: + # FIXME: publisher roster should be used, not owner + owner_roster= yield self.backend.getOwnerRoster(node, owners) + if owner_roster is None: + owner_roster = {} + if not subscriber_bare in owner_roster: + continue + #the subscriber is known, is he in the right group ? + authorized_groups = access_list[const.OPT_ROSTER_GROUPS_ALLOWED] + if owner_roster[subscriber_bare].groups.intersection(authorized_groups): + allowed_items.append(item) + else: #unknown access_model + # TODO: white list access + raise NotImplementedError + + if allowed_items: + notifications_filtered.append((subscriber, subscriptions, allowed_items)) + + defer.returnValue((owners, notifications_filtered)) + + def _preDelete(self, data, pep, recipient): + nodeIdentifier = data['node'].nodeIdentifier + redirectURI = data.get('redirectURI', None) + d = self.backend.getSubscribers(nodeIdentifier, pep, recipient) + d.addCallback(lambda subscribers: self.pubsubService.notifyDelete( + self.serviceJID, + nodeIdentifier, + subscribers, + redirectURI)) + return d + + def _mapErrors(self, failure): + e = failure.trap(*self._errorMap.keys()) + + condition, pubsubCondition, feature = self._errorMap[e] + msg = failure.value.msg + + if pubsubCondition: + exc = pubsub.PubSubError(condition, pubsubCondition, feature, msg) + else: + exc = StanzaError(condition, text=msg) + + raise exc + + def getInfo(self, requestor, service, nodeIdentifier, pep=None, recipient=None): + return [] # FIXME: disabled for now, need to manage PEP + if not requestor.resource: + # this avoid error when getting a disco request from server during namespace delegation + return [] + info = {} + + def saveType(result): + info['type'] = result + return nodeIdentifier + + def saveMetaData(result): + info['meta-data'] = result + return info + + def trapNotFound(failure): + failure.trap(error.NodeNotFound) + return info + + d = defer.succeed(nodeIdentifier) + d.addCallback(self.backend.getNodeType) + d.addCallback(saveType) + d.addCallback(self.backend.getNodeMetaData) + d.addCallback(saveMetaData) + d.addErrback(trapNotFound) + d.addErrback(self._mapErrors) + return d + + def getNodes(self, requestor, service, nodeIdentifier): + """return nodes for disco#items + + Pubsub/PEP nodes will be returned if disco node is not specified + else Pubsub/PEP items will be returned + (according to what requestor can access) + """ + try: + pep = service.pep + except AttributeError: + pep = False + + if service.resource: + return defer.succeed([]) + + if nodeIdentifier: + d = self.backend.getItemsIds(nodeIdentifier, + requestor, + [], + requestor.userhostJID() == service, + None, + None, + pep, + service) + # items must be set as name, not node + d.addCallback(lambda items: [(None, item) for item in items]) + + else: + d = self.backend.getNodes(requestor.userhostJID(), + pep, + service) + return d.addErrback(self._mapErrors) + + def getConfigurationOptions(self): + return self.backend.nodeOptions + + def _publish_errb(self, failure, request): + if failure.type == error.NodeNotFound and self.backend.supportsAutoCreate(): + print "Auto-creating node %s" % (request.nodeIdentifier,) + d = self.backend.createNode(request.nodeIdentifier, + request.sender, + pep=self._isPep(request), + recipient=request.recipient) + d.addCallback(lambda ignore, + request: self.backend.publish(request.nodeIdentifier, + request.items, + request.sender, + self._isPep(request), + request.recipient, + ), + request) + return d + + return failure + + def _isPep(self, request): + try: + return request.delegated + except AttributeError: + return False + + def publish(self, request): + d = self.backend.publish(request.nodeIdentifier, + request.items, + request.sender, + self._isPep(request), + request.recipient) + d.addErrback(self._publish_errb, request) + return d.addErrback(self._mapErrors) + + def subscribe(self, request): + d = self.backend.subscribe(request.nodeIdentifier, + request.subscriber, + request.sender, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def unsubscribe(self, request): + d = self.backend.unsubscribe(request.nodeIdentifier, + request.subscriber, + request.sender, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def subscriptions(self, request): + d = self.backend.getSubscriptions(request.sender, + request.nodeIdentifier, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def affiliations(self, request): + """Retrieve affiliation for normal entity (cf. XEP-0060 §5.7) + + retrieve all node where this jid is affiliated + """ + d = self.backend.getAffiliations(request.sender, + request.nodeIdentifier, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def create(self, request): + d = self.backend.createNode(request.nodeIdentifier, + request.sender, request.options, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def default(self, request): + d = self.backend.getDefaultConfiguration(request.nodeType, + self._isPep(request), + request.sender) + return d.addErrback(self._mapErrors) + + def configureGet(self, request): + d = self.backend.getNodeConfiguration(request.nodeIdentifier, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def configureSet(self, request): + d = self.backend.setNodeConfiguration(request.nodeIdentifier, + request.options, + request.sender, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def affiliationsGet(self, request): + """Retrieve affiliations for owner (cf. XEP-0060 §8.9.1) + + retrieve all affiliations for a node + """ + d = self.backend.getAffiliationsOwner(request.nodeIdentifier, + request.sender, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def affiliationsSet(self, request): + d = self.backend.setAffiliationsOwner(request.nodeIdentifier, + request.sender, + request.affiliations, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def subscriptionsGet(self, request): + """Retrieve subscriptions for owner (cf. XEP-0060 §8.8.1) + + retrieve all affiliations for a node + """ + d = self.backend.getSubscriptionsOwner(request.nodeIdentifier, + request.sender, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def subscriptionsSet(self, request): + d = self.backend.setSubscriptionsOwner(request.nodeIdentifier, + request.sender, + request.subscriptions, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def items(self, request): + ext_data = {} + if const.FLAG_ENABLE_RSM and request.rsm is not None: + if request.rsm.max < 0: + raise pubsub.BadRequest(text="max can't be negative") + ext_data['rsm'] = request.rsm + try: + ext_data['pep'] = request.delegated + except AttributeError: + pass + ext_data['order_by'] = request.orderBy or [] + d = self.backend.getItems(request.nodeIdentifier, + request.sender, + request.recipient, + request.maxItems, + request.itemIdentifiers, + ext_data) + return d.addErrback(self._mapErrors) + + def retract(self, request): + d = self.backend.retractItem(request.nodeIdentifier, + request.itemIdentifiers, + request.sender, + request.notify, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def purge(self, request): + d = self.backend.purgeNode(request.nodeIdentifier, + request.sender, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + + def delete(self, request): + d = self.backend.deleteNode(request.nodeIdentifier, + request.sender, + self._isPep(request), + request.recipient) + return d.addErrback(self._mapErrors) + +components.registerAdapter(PubSubResourceFromBackend, + iidavoll.IBackendService, + iwokkel.IPubSubResource) + + + +class ExtraDiscoHandler(XMPPHandler): + implements(iwokkel.IDisco) + # see comment in twisted/plugins/pubsub.py + # FIXME: upstream must be fixed so we can use custom (non pubsub#) disco features + + def getDiscoInfo(self, requestor, service, nodeIdentifier=''): + return [disco.DiscoFeature(pubsub.NS_ORDER_BY)] + + def getDiscoItems(self, requestor, service, nodeIdentifier=''): + return [] diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/const.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/const.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,85 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2019 Jérôme Poisson +# Copyright (c) 2013-2016 Adrien Cossa +# Copyright (c) 2003-2011 Ralph Meijer + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +NS_CLIENT = 'jabber:client' +NS_GROUPBLOG_PREFIX = 'urn:xmpp:groupblog:' +NS_ITEM_CONFIG = "http://jabber.org/protocol/pubsub#item-config" +NS_ATOM = "http://www.w3.org/2005/Atom" +NS_FORWARD = 'urn:xmpp:forward:0' +NS_SCHEMA = 'https://salut-a-toi/protocol/schema:0' +NS_SCHEMA_RESTRICT = 'https://salut-a-toi/protocol/schema#restrict:0' + +OPT_ACCESS_MODEL = 'pubsub#access_model' +OPT_ROSTER_GROUPS_ALLOWED = 'pubsub#roster_groups_allowed' +OPT_PERSIST_ITEMS = "pubsub#persist_items" +OPT_DELIVER_PAYLOADS = "pubsub#deliver_payloads" +OPT_SEND_LAST_PUBLISHED_ITEM = "pubsub#send_last_published_item" +OPT_PUBLISH_MODEL = 'pubsub#publish_model' +OPT_SERIAL_IDS = 'pubsub#serial_ids' +OPT_CONSISTENT_PUBLISHER = 'pubsub#consistent_publisher' +VAL_AMODEL_OPEN = 'open' +VAL_AMODEL_PRESENCE = 'presence' +VAL_AMODEL_PUBLISHER_ROSTER = 'publisher-roster' +VAL_AMODEL_WHITELIST = 'whitelist' +VAL_AMODEL_PUBLISH_ONLY = 'publish-only' +VAL_AMODEL_SELF_PUBLISHER = 'self-publisher' +VAL_AMODEL_DEFAULT = VAL_AMODEL_OPEN +VAL_AMODEL_ALL = (VAL_AMODEL_OPEN, VAL_AMODEL_PUBLISHER_ROSTER, VAL_AMODEL_WHITELIST, VAL_AMODEL_PUBLISH_ONLY, VAL_AMODEL_SELF_PUBLISHER) +VAL_PMODEL_PUBLISHERS = 'publishers' +VAL_PMODEL_SUBSCRIBERS = 'subscribers' +VAL_PMODEL_OPEN = 'open' +VAL_PMODEL_DEFAULT = VAL_PMODEL_PUBLISHERS +VAL_RSM_MAX_DEFAULT = 10 # None for no limit +FLAG_ENABLE_RSM = True +FLAG_ENABLE_MAM = True +MAM_FILTER_CATEGORY = 'http://salut-a-toi.org/protocols/mam_filter_category' diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/container.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/container.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,24 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (C) 2016 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 . + + +from collections import namedtuple + + +ItemData = namedtuple('ItemData', ('item', 'access_model', 'config', 'categories', 'created', 'updated', 'new')) +ItemData.__new__.__defaults__ = (None,) * (len(ItemData._fields) - 1) # Only item is mandatory diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/delegation.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/delegation.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,293 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- +# +# Copyright (c) 2015 Jérôme Poisson + + +# 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 . + +# --- + +# This module implements XEP-0355 (Namespace delegation) to use SàT Pubsub as PEP service + +from wokkel.subprotocols import XMPPHandler +from wokkel import pubsub +from wokkel import data_form +from wokkel import disco, iwokkel +from wokkel.iwokkel import IPubSubService +from wokkel import mam +from twisted.python import log +from twisted.words.protocols.jabber import jid, error +from twisted.words.protocols.jabber.xmlstream import toResponse +from twisted.words.xish import domish +from zope.interface import implements + +DELEGATION_NS = 'urn:xmpp:delegation:1' +FORWARDED_NS = 'urn:xmpp:forward:0' +DELEGATION_ADV_XPATH = '/message/delegation[@xmlns="{}"]'.format(DELEGATION_NS) +DELEGATION_FWD_XPATH = '/iq[@type="set"]/delegation[@xmlns="{}"]/forwarded[@xmlns="{}"]'.format(DELEGATION_NS, FORWARDED_NS) + +DELEGATION_MAIN_SEP = "::" +DELEGATION_BARE_SEP = ":bare:" + +TO_HACK = ((IPubSubService, pubsub, "PubSubRequest"), + (mam.IMAMService, mam, "MAMRequest")) + + +class InvalidStanza(Exception): + pass + + +class DelegationsHandler(XMPPHandler): + implements(iwokkel.IDisco) + _service_hacked = False + + def __init__(self): + super(DelegationsHandler, self).__init__() + + def _service_hack(self): + """Patch the request classes of services to track delegated stanzas""" + # XXX: we need to monkey patch to track origin of the stanza in PubSubRequest. + # As PubSubRequest from sat.tmp.wokkel.pubsub use _request_class while + # original wokkel.pubsub use directly pubsub.PubSubRequest, we need to + # check which version is used before monkeypatching + for handler in self.parent.handlers: + for service, module, default_base_cls in TO_HACK: + if service.providedBy(handler): + if hasattr(handler, '_request_class'): + request_base_class = handler._request_class + else: + request_base_class = getattr(module, default_base_cls) + + class RequestWithDelegation(request_base_class): + """A XxxRequest which put an indicator if the stanza comme from delegation""" + + @classmethod + def fromElement(cls, element): + """Check if element comme from delegation, and set a delegated flags + + delegated flag is either False, or it's a jid of the delegating server + the delegated flag must be set on element before use + """ + try: + # __getattr__ is overriden in domish.Element, so we use __getattribute__ + delegated = element.__getattribute__('delegated') + except AttributeError: + delegated = False + instance = cls.__base__.fromElement(element) + instance.delegated = delegated + return instance + + if hasattr(handler, '_request_class'): + handler._request_class = RequestWithDelegation + else: + setattr(module, default_base_cls, RequestWithDelegation) + DelegationsHandler._service_hacked = True + + def connectionInitialized(self): + if not self._service_hacked: + self._service_hack() + self.xmlstream.addObserver(DELEGATION_ADV_XPATH, self.onAdvertise) + self.xmlstream.addObserver(DELEGATION_FWD_XPATH, self._obsWrapper, 0, self.onForward) + self._current_iqs = {} # dict of iq being handler by delegation + self._xs_send = self.xmlstream.send + self.xmlstream.send = self._sendHack + + def _sendHack(self, elt): + """This method is called instead of xmlstream to control sending + + @param obj(domsish.Element, unicode, str): obj sent to real xmlstream + """ + if isinstance(elt, domish.Element) and elt.name=='iq': + try: + id_ = elt.getAttribute('id') + ori_iq, managed_entity = self._current_iqs[id_] + if jid.JID(elt['to']) != managed_entity: + log.msg("IQ id conflict: the managed entity doesn't match (got {got} was expecting {expected})" + .format(got=jid.JID(elt['to']), expected=managed_entity)) + raise KeyError + except KeyError: + # the iq is not a delegated one + self._xs_send(elt) + else: + del self._current_iqs[id_] + iq_result_elt = toResponse(ori_iq, 'result') + fwd_elt = iq_result_elt.addElement('delegation', DELEGATION_NS).addElement('forwarded', FORWARDED_NS) + fwd_elt.addChild(elt) + elt.uri = elt.defaultUri = 'jabber:client' + self._xs_send(iq_result_elt) + else: + self._xs_send(elt) + + def _obsWrapper(self, observer, stanza): + """Wrapper to observer which catch StanzaError + + @param observer(callable): method to wrap + """ + try: + observer(stanza) + except error.StanzaError as e: + error_elt = e.toResponse(stanza) + self._xs_send(error_elt) + stanza.handled = True + + def onAdvertise(self, message): + """Manage the advertising delegations""" + delegation_elt = message.elements(DELEGATION_NS, 'delegation').next() + delegated = {} + for delegated_elt in delegation_elt.elements(DELEGATION_NS): + try: + if delegated_elt.name != 'delegated': + raise InvalidStanza(u'unexpected element {}'.format(delegated_elt.name)) + try: + namespace = delegated_elt['namespace'] + except KeyError: + raise InvalidStanza(u'was expecting a "namespace" attribute in delegated element') + delegated[namespace] = [] + for attribute_elt in delegated_elt.elements(DELEGATION_NS, 'attribute'): + try: + delegated[namespace].append(attribute_elt["name"]) + except KeyError: + raise InvalidStanza(u'was expecting a "name" attribute in attribute element') + except InvalidStanza as e: + log.msg("Invalid stanza received ({})".format(e)) + + log.msg(u'delegations updated:\n{}'.format( + u'\n'.join([u" - namespace {}{}".format(ns, + u"" if not attributes else u" with filtering on {} attribute(s)".format( + u", ".join(attributes))) for ns, attributes in delegated.items()]))) + + if not pubsub.NS_PUBSUB in delegated: + log.msg(u"Didn't got pubsub delegation from server, can't act as a PEP service") + + def onForward(self, iq): + """Manage forwarded iq + + @param iq(domish.Element): full delegation stanza + """ + + # FIXME: we use a hack supposing that our delegation come from hostname + # and we are a component named [name].hostname + # but we need to manage properly allowed servers + # TODO: do proper origin security check + _, allowed = iq['to'].split('.', 1) + if jid.JID(iq['from']) != jid.JID(allowed): + log.msg((u"SECURITY WARNING: forwarded stanza doesn't come from our server: {}" + .format(iq.toXml())).encode('utf-8')) + raise error.StanzaError('not-allowed') + + try: + fwd_iq = (iq.elements(DELEGATION_NS, 'delegation').next() + .elements(FORWARDED_NS, 'forwarded').next() + .elements('jabber:client', 'iq').next()) + except StopIteration: + raise error.StanzaError('not-acceptable') + + managed_entity = jid.JID(fwd_iq['from']) + + self._current_iqs[fwd_iq['id']] = (iq, managed_entity) + fwd_iq.delegated = True + + # we need a recipient in pubsub request for PEP + # so we set "to" attribute if it doesn't exist + if not fwd_iq.hasAttribute('to'): + fwd_iq["to"] = jid.JID(fwd_iq["from"]).userhost() + + # we now inject the element in the stream + self.xmlstream.dispatch(fwd_iq) + + def getDiscoInfo(self, requestor, target, nodeIdentifier=''): + """Manage disco nesting + + This method looks for DiscoHandler in sibling handlers and use it to + collect main disco infos. It then filters by delegated namespace and return it. + An identity is added for PEP if pubsub namespace is requested. + + The same features/identities are returned for main and bare nodes + """ + if not nodeIdentifier.startswith(DELEGATION_NS): + return [] + + try: + _, namespace = nodeIdentifier.split(DELEGATION_MAIN_SEP, 1) + except ValueError: + try: + _, namespace = nodeIdentifier.split(DELEGATION_BARE_SEP, 1) + except ValueError: + log.msg("Unexpected disco node: {}".format(nodeIdentifier)) + raise error.StanzaError('not-acceptable') + + if not namespace: + log.msg("No namespace found in node {}".format(nodeIdentifier)) + return [] + + if namespace.startswith(pubsub.NS_PUBSUB): + # pubsub use several namespaces starting with NS_PUBSUB (e.g. http://jabber.org/protocol/pubsub#owner) + # we return the same disco for all of them + namespace = pubsub.NS_PUBSUB + + def gotInfos(infos): + ns_features = [] + for info in infos: + if isinstance(info, disco.DiscoFeature) and info.startswith(namespace): + ns_features.append(info) + elif (isinstance(info, data_form.Form) and info.formNamespace + and info.formNamespace.startwith(namespace)): + # extensions management (XEP-0128) + ns_features.append(info) + + if namespace == pubsub.NS_PUBSUB: + ns_features.append(disco.DiscoIdentity('pubsub', 'pep')) + + return ns_features + + for handler in self.parent.handlers: + if isinstance(handler, disco.DiscoHandler): + break + + if not isinstance(handler, disco.DiscoHandler): + log.err("Can't find DiscoHandler") + return [] + + d = handler.info(requestor, target, '') + d.addCallback(gotInfos) + return d + + def getDiscoItems(self, requestor, target, nodeIdentifier=''): + return [] + + +# we monkeypatch DiscoHandler to add delegation informations +def _onDiscoItems(self, iq): + request = disco._DiscoRequest.fromElement(iq) + # it's really ugly to attach pep data to recipient + # but we don't have many options + request.recipient.pep = iq.delegated + + def toResponse(items): + response = disco.DiscoItems() + response.nodeIdentifier = request.nodeIdentifier + + for item in items: + response.append(item) + + return response.toElement() + + d = self.items(request.sender, request.recipient, + request.nodeIdentifier) + d.addCallback(toResponse) + return d + + +disco.DiscoHandler._onDiscoItems = _onDiscoItems diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/error.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/error.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,152 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2019 Jérôme Poisson +# Copyright (c) 2013-2016 Adrien Cossa +# Copyright (c) 2003-2011 Ralph Meijer + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +class Error(Exception): + msg = '' + + def __init__(self, msg=None): + self.msg = msg or self.msg + + + def __str__(self): + return self.msg + + +class Deprecated(Exception): + pass + + +class NodeNotFound(Error): + pass + + +class NodeExists(Error): + pass + + +class NotSubscribed(Error): + """ + Entity is not subscribed to this node. + """ + + +class SubscriptionExists(Error): + """ + There already exists a subscription to this node. + """ + + +def NotLeafNodeError(Error): + """a leaf node is expected but we have a collection""" + + +class Forbidden(Error): + pass + + +class NotAuthorized(Error): + pass + + +class NotInRoster(Error): + pass + + +class ItemNotFound(Error): + pass + + +class ItemForbidden(Error): + pass + + +class ItemRequired(Error): + pass + + +class NoInstantNodes(Error): + pass + + +class InvalidConfigurationOption(Error): + msg = 'Invalid configuration option' + + +class InvalidConfigurationValue(Error): + msg = 'Bad configuration value' + + +class NodeNotPersistent(Error): + pass + + +class NoRootNode(Error): + pass + + +class NoCallbacks(Error): + """ + There are no callbacks for this node. + """ + +class NoCollections(Error): + pass + + +class NoPublishing(Error): + """ + This node does not support publishing. + """ + +class BadAccessTypeError(Error): + pass diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/exceptions.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/exceptions.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,55 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2019 Jérôme Poisson +# Copyright (c) 2013-2016 Adrien Cossa +# Copyright (c) 2003-2011 Ralph Meijer + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +class InternalError(Exception): + pass diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/gateway.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/gateway.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,899 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2019 Jérôme Poisson + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +""" +Web resources and client for interacting with pubsub services. +""" + +import mimetools +from time import gmtime, strftime +from StringIO import StringIO +import urllib +import urlparse + +import simplejson + +from twisted.application import service +from twisted.internet import defer, reactor +from twisted.python import log +from twisted.web import client, http, resource, server +from twisted.web.error import Error +from twisted.words.protocols.jabber.jid import JID +from twisted.words.protocols.jabber.error import StanzaError +from twisted.words.xish import domish + +from wokkel.generic import parseXml +from wokkel.pubsub import Item +from wokkel.pubsub import PubSubClient + +from sat_pubsub import error + +NS_ATOM = 'http://www.w3.org/2005/Atom' +MIME_ATOM_ENTRY = b'application/atom+xml;type=entry' +MIME_ATOM_FEED = b'application/atom+xml;type=feed' +MIME_JSON = b'application/json' + +class XMPPURIParseError(ValueError): + """ + Raised when a given XMPP URI couldn't be properly parsed. + """ + + + +def getServiceAndNode(uri): + """ + Given an XMPP URI, extract the publish subscribe service JID and node ID. + """ + + try: + scheme, rest = uri.split(':', 1) + except ValueError: + raise XMPPURIParseError("No URI scheme component") + + if scheme != 'xmpp': + raise XMPPURIParseError("Unknown URI scheme") + + if rest.startswith("//"): + raise XMPPURIParseError("Unexpected URI authority component") + + try: + entity, query = rest.split('?', 1) + except ValueError: + entity, query = rest, '' + + if not entity: + raise XMPPURIParseError("Empty URI path component") + + try: + service = JID(entity) + except Exception, e: + raise XMPPURIParseError("Invalid JID: %s" % e) + + params = urlparse.parse_qs(query) + + try: + nodeIdentifier = params['node'][0] + except (KeyError, ValueError): + nodeIdentifier = '' + + return service, nodeIdentifier + + + +def getXMPPURI(service, nodeIdentifier): + """ + Construct an XMPP URI from a service JID and node identifier. + """ + return "xmpp:%s?;node=%s" % (service.full(), nodeIdentifier or '') + + + +def _parseContentType(header): + """ + Parse a Content-Type header value to a L{mimetools.Message}. + + L{mimetools.Message} parses a Content-Type header and makes the + components available with its C{getmaintype}, C{getsubtype}, C{gettype}, + C{getplist} and C{getparam} methods. + """ + return mimetools.Message(StringIO(b'Content-Type: ' + header)) + + + +def _asyncResponse(render): + """ + """ + def wrapped(self, request): + def eb(failure): + if failure.check(Error): + err = failure.value + else: + log.err(failure) + err = Error(500) + request.setResponseCode(err.status, err.message) + return err.response + + def finish(result): + if result is server.NOT_DONE_YET: + return + + if result: + request.write(result) + request.finish() + + d = defer.maybeDeferred(render, self, request) + d.addErrback(eb) + d.addCallback(finish) + + return server.NOT_DONE_YET + + return wrapped + + + +class CreateResource(resource.Resource): + """ + A resource to create a publish-subscribe node. + """ + def __init__(self, backend, serviceJID, owner): + self.backend = backend + self.serviceJID = serviceJID + self.owner = owner + + + http_GET = None + + + @_asyncResponse + def render_POST(self, request): + """ + Respond to a POST request to create a new node. + """ + + def toResponse(nodeIdentifier): + uri = getXMPPURI(self.serviceJID, nodeIdentifier) + body = simplejson.dumps({'uri': uri}) + request.setHeader(b'Content-Type', MIME_JSON) + return body + + d = self.backend.createNode(None, self.owner) + d.addCallback(toResponse) + return d + + + +class DeleteResource(resource.Resource): + """ + A resource to create a publish-subscribe node. + """ + def __init__(self, backend, serviceJID, owner): + self.backend = backend + self.serviceJID = serviceJID + self.owner = owner + + + render_GET = None + + + @_asyncResponse + def render_POST(self, request): + """ + Respond to a POST request to create a new node. + """ + def toResponse(result): + request.setResponseCode(http.NO_CONTENT) + + def trapNotFound(failure): + failure.trap(error.NodeNotFound) + raise Error(http.NOT_FOUND, "Node not found") + + if not request.args.get('uri'): + raise Error(http.BAD_REQUEST, "No URI given") + + try: + jid, nodeIdentifier = getServiceAndNode(request.args['uri'][0]) + except XMPPURIParseError, e: + raise Error(http.BAD_REQUEST, "Malformed XMPP URI: %s" % e) + + + data = request.content.read() + if data: + params = simplejson.loads(data) + redirectURI = params.get('redirect_uri', None) + else: + redirectURI = None + + d = self.backend.deleteNode(nodeIdentifier, self.owner, + redirectURI) + d.addCallback(toResponse) + d.addErrback(trapNotFound) + return d + + + +class PublishResource(resource.Resource): + """ + A resource to publish to a publish-subscribe node. + """ + + def __init__(self, backend, serviceJID, owner): + self.backend = backend + self.serviceJID = serviceJID + self.owner = owner + + + render_GET = None + + + def checkMediaType(self, request): + ctype = request.getHeader(b'content-type') + + if not ctype: + request.setResponseCode(http.BAD_REQUEST) + + raise Error(http.BAD_REQUEST, b"No specified Media Type") + + message = _parseContentType(ctype) + if (message.maintype != b'application' or + message.subtype != b'atom+xml' or + message.getparam(b'type') != b'entry' or + (message.getparam(b'charset') or b'utf-8') != b'utf-8'): + raise Error(http.UNSUPPORTED_MEDIA_TYPE, + b"Unsupported Media Type: %s" % ctype) + + + @_asyncResponse + def render_POST(self, request): + """ + Respond to a POST request to create a new item. + """ + + def toResponse(nodeIdentifier): + uri = getXMPPURI(self.serviceJID, nodeIdentifier) + body = simplejson.dumps({'uri': uri}) + request.setHeader(b'Content-Type', MIME_JSON) + return body + + def gotNode(nodeIdentifier, payload): + item = Item(id='current', payload=payload) + d = self.backend.publish(nodeIdentifier, [item], self.owner) + d.addCallback(lambda _: nodeIdentifier) + return d + + def getNode(): + if request.args.get('uri'): + jid, nodeIdentifier = getServiceAndNode(request.args['uri'][0]) + return defer.succeed(nodeIdentifier) + else: + return self.backend.createNode(None, self.owner) + + def trapNotFound(failure): + failure.trap(error.NodeNotFound) + raise Error(http.NOT_FOUND, "Node not found") + + def trapXMPPURIParseError(failure): + failure.trap(XMPPURIParseError) + raise Error(http.BAD_REQUEST, + "Malformed XMPP URI: %s" % failure.value) + + self.checkMediaType(request) + payload = parseXml(request.content.read()) + d = getNode() + d.addCallback(gotNode, payload) + d.addCallback(toResponse) + d.addErrback(trapNotFound) + d.addErrback(trapXMPPURIParseError) + return d + + + +class ListResource(resource.Resource): + def __init__(self, service): + self.service = service + + + @_asyncResponse + def render_GET(self, request): + def responseFromNodes(nodeIdentifiers): + body = simplejson.dumps(nodeIdentifiers) + request.setHeader(b'Content-Type', MIME_JSON) + return body + + d = self.service.getNodes() + d.addCallback(responseFromNodes) + return d + + + +# Service for subscribing to remote XMPP Pubsub nodes and web resources + +def extractAtomEntries(items): + """ + Extract atom entries from a list of publish-subscribe items. + + @param items: List of L{domish.Element}s that represent publish-subscribe + items. + @type items: C{list} + """ + + atomEntries = [] + + for item in items: + # ignore non-items (i.e. retractions) + if item.name != 'item': + continue + + atomEntry = None + for element in item.elements(): + # extract the first element that is an atom entry + if element.uri == NS_ATOM and element.name == 'entry': + atomEntry = element + break + + if atomEntry: + atomEntries.append(atomEntry) + + return atomEntries + + + +def constructFeed(service, nodeIdentifier, entries, title): + nodeURI = getXMPPURI(service, nodeIdentifier) + now = strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) + + # Collect the received entries in a feed + feed = domish.Element((NS_ATOM, 'feed')) + feed.addElement('title', content=title) + feed.addElement('id', content=nodeURI) + feed.addElement('updated', content=now) + + for entry in entries: + feed.addChild(entry) + + return feed + + + +class RemoteSubscriptionService(service.Service, PubSubClient): + """ + Service for subscribing to remote XMPP Publish-Subscribe nodes. + + Subscriptions are created with a callback HTTP URI that is POSTed + to with the received items in notifications. + """ + + def __init__(self, jid, storage): + self.jid = jid + self.storage = storage + + + def trapNotFound(self, failure): + failure.trap(StanzaError) + + if failure.value.condition == 'item-not-found': + raise error.NodeNotFound() + else: + return failure + + + def subscribeCallback(self, jid, nodeIdentifier, callback): + """ + Subscribe a callback URI. + + This registers a callback URI to be called when a notification is + received for the given node. + + If this is the first callback registered for this node, the gateway + will subscribe to the node. Otherwise, the most recently published item + for this node is retrieved and, if present, the newly registered + callback will be called with that item. + """ + + def callbackForLastItem(items): + atomEntries = extractAtomEntries(items) + + if not atomEntries: + return + + self._postTo([callback], jid, nodeIdentifier, atomEntries[0], + MIME_ATOM_ENTRY) + + def subscribeOrItems(hasCallbacks): + if hasCallbacks: + if not nodeIdentifier: + return None + d = self.items(jid, nodeIdentifier, 1) + d.addCallback(callbackForLastItem) + else: + d = self.subscribe(jid, nodeIdentifier, self.jid) + + d.addErrback(self.trapNotFound) + return d + + d = self.storage.hasCallbacks(jid, nodeIdentifier) + d.addCallback(subscribeOrItems) + d.addCallback(lambda _: self.storage.addCallback(jid, nodeIdentifier, + callback)) + return d + + + def unsubscribeCallback(self, jid, nodeIdentifier, callback): + """ + Unsubscribe a callback. + + If this was the last registered callback for this node, the + gateway will unsubscribe from node. + """ + + def cb(last): + if last: + return self.unsubscribe(jid, nodeIdentifier, self.jid) + + d = self.storage.removeCallback(jid, nodeIdentifier, callback) + d.addCallback(cb) + return d + + + def itemsReceived(self, event): + """ + Fire up HTTP client to do callback + """ + + atomEntries = extractAtomEntries(event.items) + service = event.sender + nodeIdentifier = event.nodeIdentifier + headers = event.headers + + # Don't notify if there are no atom entries + if not atomEntries: + return + + if len(atomEntries) == 1: + contentType = MIME_ATOM_ENTRY + payload = atomEntries[0] + else: + contentType = MIME_ATOM_FEED + payload = constructFeed(service, nodeIdentifier, atomEntries, + title='Received item collection') + + self.callCallbacks(service, nodeIdentifier, payload, contentType) + + if 'Collection' in headers: + for collection in headers['Collection']: + nodeIdentifier = collection or '' + self.callCallbacks(service, nodeIdentifier, payload, + contentType) + + + def deleteReceived(self, event): + """ + Fire up HTTP client to do callback + """ + + service = event.sender + nodeIdentifier = event.nodeIdentifier + redirectURI = event.redirectURI + self.callCallbacks(service, nodeIdentifier, eventType='DELETED', + redirectURI=redirectURI) + + + def _postTo(self, callbacks, service, nodeIdentifier, + payload=None, contentType=None, eventType=None, + redirectURI=None): + + if not callbacks: + return + + postdata = None + nodeURI = getXMPPURI(service, nodeIdentifier) + headers = {'Referer': nodeURI.encode('utf-8'), + 'PubSub-Service': service.full().encode('utf-8')} + + if payload: + postdata = payload.toXml().encode('utf-8') + if contentType: + headers['Content-Type'] = "%s;charset=utf-8" % contentType + + if eventType: + headers['Event'] = eventType + + if redirectURI: + headers['Link'] = '<%s>; rel=alternate' % ( + redirectURI.encode('utf-8'), + ) + + def postNotification(callbackURI): + f = getPageWithFactory(str(callbackURI), + method='POST', + postdata=postdata, + headers=headers) + d = f.deferred + d.addErrback(log.err) + + for callbackURI in callbacks: + reactor.callLater(0, postNotification, callbackURI) + + + def callCallbacks(self, service, nodeIdentifier, + payload=None, contentType=None, eventType=None, + redirectURI=None): + + def eb(failure): + failure.trap(error.NoCallbacks) + + # No callbacks were registered for this node. Unsubscribe? + + d = self.storage.getCallbacks(service, nodeIdentifier) + d.addCallback(self._postTo, service, nodeIdentifier, payload, + contentType, eventType, redirectURI) + d.addErrback(eb) + d.addErrback(log.err) + + + +class RemoteSubscribeBaseResource(resource.Resource): + """ + Base resource for remote pubsub node subscription and unsubscription. + + This resource accepts POST request with a JSON document that holds + a dictionary with the keys C{uri} and C{callback} that respectively map + to the XMPP URI of the publish-subscribe node and the callback URI. + + This class should be inherited with L{serviceMethod} overridden. + + @cvar serviceMethod: The name of the method to be called with + the JID of the pubsub service, the node identifier + and the callback URI as received in the HTTP POST + request to this resource. + """ + serviceMethod = None + errorMap = { + error.NodeNotFound: + (http.FORBIDDEN, "Node not found"), + error.NotSubscribed: + (http.FORBIDDEN, "No such subscription found"), + error.SubscriptionExists: + (http.FORBIDDEN, "Subscription already exists"), + } + + def __init__(self, service): + self.service = service + self.params = None + + + render_GET = None + + + @_asyncResponse + def render_POST(self, request): + def trapNotFound(failure): + err = failure.trap(*self.errorMap.keys()) + status, message = self.errorMap[err] + raise Error(status, message) + + def toResponse(result): + request.setResponseCode(http.NO_CONTENT) + return b'' + + def trapXMPPURIParseError(failure): + failure.trap(XMPPURIParseError) + raise Error(http.BAD_REQUEST, + "Malformed XMPP URI: %s" % failure.value) + + data = request.content.read() + self.params = simplejson.loads(data) + + uri = self.params['uri'] + callback = self.params['callback'] + + jid, nodeIdentifier = getServiceAndNode(uri) + method = getattr(self.service, self.serviceMethod) + d = method(jid, nodeIdentifier, callback) + d.addCallback(toResponse) + d.addErrback(trapNotFound) + d.addErrback(trapXMPPURIParseError) + return d + + + +class RemoteSubscribeResource(RemoteSubscribeBaseResource): + """ + Resource to subscribe to a remote publish-subscribe node. + + The passed C{uri} is the XMPP URI of the node to subscribe to and the + C{callback} is the callback URI. Upon receiving notifications from the + node, a POST request will be perfomed on the callback URI. + """ + serviceMethod = 'subscribeCallback' + + + +class RemoteUnsubscribeResource(RemoteSubscribeBaseResource): + """ + Resource to unsubscribe from a remote publish-subscribe node. + + The passed C{uri} is the XMPP URI of the node to unsubscribe from and the + C{callback} is the callback URI that was registered for it. + """ + serviceMethod = 'unsubscribeCallback' + + + +class RemoteItemsResource(resource.Resource): + """ + Resource for retrieving items from a remote pubsub node. + """ + + def __init__(self, service): + self.service = service + + + @_asyncResponse + def render_GET(self, request): + try: + maxItems = int(request.args.get('max_items', [0])[0]) or None + except ValueError: + raise Error(http.BAD_REQUEST, + "The argument max_items has an invalid value.") + + try: + uri = request.args['uri'][0] + except KeyError: + raise Error(http.BAD_REQUEST, + "No URI for the remote node provided.") + + try: + jid, nodeIdentifier = getServiceAndNode(uri) + except XMPPURIParseError: + raise Error(http.BAD_REQUEST, + "Malformed XMPP URI: %s" % uri) + + def toResponse(items): + """ + Create a feed out the retrieved items. + """ + atomEntries = extractAtomEntries(items) + feed = constructFeed(jid, nodeIdentifier, atomEntries, + "Retrieved item collection") + body = feed.toXml().encode('utf-8') + request.setHeader(b'Content-Type', MIME_ATOM_FEED) + return body + + def trapNotFound(failure): + failure.trap(StanzaError) + if not failure.value.condition == 'item-not-found': + raise failure + raise Error(http.NOT_FOUND, "Node not found") + + d = self.service.items(jid, nodeIdentifier, maxItems) + d.addCallback(toResponse) + d.addErrback(trapNotFound) + return d + + + +# Client side code to interact with a service as provided above + +def getPageWithFactory(url, contextFactory=None, *args, **kwargs): + """Download a web page. + + Download a page. Return the factory that holds a deferred, which will + callback with a page (as a string) or errback with a description of the + error. + + See HTTPClientFactory to see what extra args can be passed. + """ + + factory = client.HTTPClientFactory(url, *args, **kwargs) + factory.protocol.handleStatus_204 = lambda self: self.handleStatus_200() + + if factory.scheme == 'https': + from twisted.internet import ssl + if contextFactory is None: + contextFactory = ssl.ClientContextFactory() + reactor.connectSSL(factory.host, factory.port, factory, contextFactory) + else: + reactor.connectTCP(factory.host, factory.port, factory) + return factory + + + +class CallbackResource(resource.Resource): + """ + Web resource for retrieving gateway notifications. + """ + + def __init__(self, callback): + self.callback = callback + + + http_GET = None + + + def render_POST(self, request): + if request.requestHeaders.hasHeader(b'Event'): + payload = None + else: + payload = parseXml(request.content.read()) + + self.callback(payload, request.requestHeaders) + + request.setResponseCode(http.NO_CONTENT) + return b'' + + + + +class GatewayClient(service.Service): + """ + Service that provides client access to the HTTP Gateway into Idavoll. + """ + + agent = "Idavoll HTTP Gateway Client" + + def __init__(self, baseURI, callbackHost=None, callbackPort=None): + self.baseURI = baseURI + self.callbackHost = callbackHost or 'localhost' + self.callbackPort = callbackPort or 8087 + root = resource.Resource() + root.putChild('callback', CallbackResource( + lambda *args, **kwargs: self.callback(*args, **kwargs))) + self.site = server.Site(root) + + + def startService(self): + self.port = reactor.listenTCP(self.callbackPort, + self.site) + + + def stopService(self): + return self.port.stopListening() + + + def _makeURI(self, verb, query=None): + uriComponents = urlparse.urlparse(self.baseURI) + uri = urlparse.urlunparse((uriComponents[0], + uriComponents[1], + uriComponents[2] + verb, + '', + query and urllib.urlencode(query) or '', + '')) + return uri + + + def callback(self, data, headers): + pass + + + def ping(self): + f = getPageWithFactory(self._makeURI(''), + method='HEAD', + agent=self.agent) + return f.deferred + + + def create(self): + f = getPageWithFactory(self._makeURI('create'), + method='POST', + agent=self.agent) + return f.deferred.addCallback(simplejson.loads) + + + def delete(self, xmppURI, redirectURI=None): + query = {'uri': xmppURI} + + if redirectURI: + params = {'redirect_uri': redirectURI} + postdata = simplejson.dumps(params) + headers = {'Content-Type': MIME_JSON} + else: + postdata = None + headers = None + + f = getPageWithFactory(self._makeURI('delete', query), + method='POST', + postdata=postdata, + headers=headers, + agent=self.agent) + return f.deferred + + + def publish(self, entry, xmppURI=None): + query = xmppURI and {'uri': xmppURI} + + f = getPageWithFactory(self._makeURI('publish', query), + method='POST', + postdata=entry.toXml().encode('utf-8'), + headers={'Content-Type': MIME_ATOM_ENTRY}, + agent=self.agent) + return f.deferred.addCallback(simplejson.loads) + + + def listNodes(self): + f = getPageWithFactory(self._makeURI('list'), + method='GET', + agent=self.agent) + return f.deferred.addCallback(simplejson.loads) + + + def subscribe(self, xmppURI): + params = {'uri': xmppURI, + 'callback': 'http://%s:%s/callback' % (self.callbackHost, + self.callbackPort)} + f = getPageWithFactory(self._makeURI('subscribe'), + method='POST', + postdata=simplejson.dumps(params), + headers={'Content-Type': MIME_JSON}, + agent=self.agent) + return f.deferred + + + def unsubscribe(self, xmppURI): + params = {'uri': xmppURI, + 'callback': 'http://%s:%s/callback' % (self.callbackHost, + self.callbackPort)} + f = getPageWithFactory(self._makeURI('unsubscribe'), + method='POST', + postdata=simplejson.dumps(params), + headers={'Content-Type': MIME_JSON}, + agent=self.agent) + return f.deferred + + + def items(self, xmppURI, maxItems=None): + query = {'uri': xmppURI} + if maxItems is not None: + query['max_items'] = int(maxItems) + f = getPageWithFactory(self._makeURI('items', query), + method='GET', + agent=self.agent) + return f.deferred diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/iidavoll.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/iidavoll.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,665 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2019 Jérôme Poisson + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +""" +Interfaces for idavoll. +""" + +from zope.interface import Attribute, Interface + +class IBackendService(Interface): + """ Interface to a backend service of a pubsub service. """ + + + def __init__(storage): + """ + @param storage: Object providing L{IStorage}. + """ + + + def supportsPublisherAffiliation(): + """ Reports if the backend supports the publisher affiliation. + + @rtype: C{bool} + """ + + + def supportsOutcastAffiliation(): + """ Reports if the backend supports the publisher affiliation. + + @rtype: C{bool} + """ + + + def supportsPersistentItems(): + """ Reports if the backend supports persistent items. + + @rtype: C{bool} + """ + + + def getNodeType(nodeIdentifier): + """ Return type of a node. + + @return: a deferred that returns either 'leaf' or 'collection' + """ + + + def getNodes(): + """ Returns list of all nodes. + + @return: a deferred that returns a C{list} of node ids. + """ + + + def getNodeMetaData(nodeIdentifier): + """ Return meta data for a node. + + @return: a deferred that returns a C{list} of C{dict}s with the + metadata. + """ + + + def createNode(nodeIdentifier, requestor): + """ Create a node. + + @return: a deferred that fires when the node has been created. + """ + + + def registerPreDelete(preDeleteFn): + """ Register a callback that is called just before a node deletion. + + The function C{preDeletedFn} is added to a list of functions to be + called just before deletion of a node. The callback C{preDeleteFn} is + called with the C{nodeIdentifier} that is about to be deleted and + should return a deferred that returns a list of deferreds that are to + be fired after deletion. The backend collects the lists from all these + callbacks before actually deleting the node in question. After + deletion all collected deferreds are fired to do post-processing. + + The idea is that you want to be able to collect data from the node + before deleting it, for example to get a list of subscribers that have + to be notified after the node has been deleted. To do this, + C{preDeleteFn} fetches the subscriber list and passes this list to a + callback attached to a deferred that it sets up. This deferred is + returned in the list of deferreds. + """ + + + def deleteNode(nodeIdentifier, requestor): + """ Delete a node. + + @return: a deferred that fires when the node has been deleted. + """ + + + def purgeNode(nodeIdentifier, requestor): + """ Removes all items in node from persistent storage """ + + + def subscribe(nodeIdentifier, subscriber, requestor): + """ Request the subscription of an entity to a pubsub node. + + Depending on the node's configuration and possible business rules, the + C{subscriber} is added to the list of subscriptions of the node with id + C{nodeIdentifier}. The C{subscriber} might be different from the + C{requestor}, and if the C{requestor} is not allowed to subscribe this + entity an exception should be raised. + + @return: a deferred that returns the subscription state + """ + + + def unsubscribe(nodeIdentifier, subscriber, requestor): + """ Cancel the subscription of an entity to a pubsub node. + + The subscription of C{subscriber} is removed from the list of + subscriptions of the node with id C{nodeIdentifier}. If the + C{requestor} is not allowed to unsubscribe C{subscriber}, an an + exception should be raised. + + @return: a deferred that fires when unsubscription is complete. + """ + + + def getSubscribers(nodeIdentifier): + """ Get node subscriber list. + + @return: a deferred that fires with the list of subscribers. + """ + + + def getSubscriptions(entity): + """ Report the list of current subscriptions with this pubsub service. + + Report the list of the current subscriptions with all nodes within this + pubsub service, for the C{entity}. + + @return: a deferred that returns the list of all current subscriptions + as tuples C{(nodeIdentifier, subscriber, subscription)}. + """ + + + def getAffiliations(entity): + """ Report the list of current affiliations with this pubsub service. + + Report the list of the current affiliations with all nodes within this + pubsub service, for the C{entity}. + + @return: a deferred that returns the list of all current affiliations + as tuples C{(nodeIdentifier, affiliation)}. + """ + + + def publish(nodeIdentifier, items, requestor): + """ Publish items to a pubsub node. + + @return: a deferred that fires when the items have been published. + @rtype: L{Deferred} + """ + + + def registerNotifier(observerfn, *args, **kwargs): + """ Register callback which is called for notification. """ + + + def getNotifications(nodeIdentifier, items): + """ + Get notification list. + + This method is called to discover which entities should receive + notifications for the given items that have just been published to the + given node. + + The notification list contains tuples (subscriber, subscriptions, + items) to result in one notification per tuple: the given subscriptions + yielded the given items to be notified to this subscriber. This + structure is needed allow for letting the subscriber know which + subscriptions yielded which notifications, while catering for + collection nodes and content-based subscriptions. + + To minimize the amount of notifications per entity, implementers + should take care that if all items in C{items} were yielded + by the same set of subscriptions, exactly one tuple is for this + subscriber is returned, so that the subscriber would get exactly one + notification. Alternatively, one tuple per subscription combination. + + @param nodeIdentifier: The identifier of the node the items were + published to. + @type nodeIdentifier: C{unicode}. + @param items: The list of published items as + L{Element}s. + @type items: C{list} + @return: The notification list as tuples of + (L{JID}, + C{list} of L{Subscription}, + C{list} of L{Element}. + @rtype: C{list} + """ + + + def getItems(nodeIdentifier, requestor, maxItems=None, itemIdentifiers=[]): + """ Retrieve items from persistent storage + + If C{maxItems} is given, return the C{maxItems} last published + items, else if C{itemIdentifiers} is not empty, return the items + requested. If neither is given, return all items. + + @return: a deferred that returns the requested items + """ + + + def retractItem(nodeIdentifier, itemIdentifier, requestor): + """ Removes item in node from persistent storage """ + + + +class IStorage(Interface): + """ + Storage interface. + """ + + + def getNode(nodeIdentifier): + """ + Get Node. + + @param nodeIdentifier: NodeID of the desired node. + @type nodeIdentifier: C{str} + @return: deferred that returns a L{INode} providing object. + """ + + + def getNodeIds(): + """ + Return all NodeIDs. + + @return: deferred that returns a list of NodeIDs (C{unicode}). + """ + + + def createNode(nodeIdentifier, owner, config): + """ + Create new node. + + The implementation should make sure, the passed owner JID is stripped + of the resource (e.g. using C{owner.userhostJID()}). The passed config + is expected to have values for the fields returned by + L{getDefaultConfiguration}, as well as a value for + C{'pubsub#node_type'}. + + @param nodeIdentifier: NodeID of the new node. + @type nodeIdentifier: C{unicode} + @param owner: JID of the new nodes's owner. + @type owner: L{JID} + @param config: Node configuration. + @type config: C{dict} + @return: deferred that fires on creation. + """ + + + def deleteNode(nodeIdentifier): + """ + Delete a node. + + @param nodeIdentifier: NodeID of the new node. + @type nodeIdentifier: C{unicode} + @return: deferred that fires on deletion. + """ + + + def getAffiliations(entity): + """ + Get all affiliations for entity. + + The implementation should make sure, the passed owner JID is stripped + of the resource (e.g. using C{owner.userhostJID()}). + + @param entity: JID of the entity. + @type entity: L{JID} + @return: deferred that returns a C{list} of tuples of the form + C{(nodeIdentifier, affiliation)}, where C{nodeIdentifier} is + of the type L{unicode} and C{affiliation} is one of + C{'owner'}, C{'publisher'} and C{'outcast'}. + """ + + + def getSubscriptions(entity): + """ + Get all subscriptions for an entity. + + The implementation should make sure, the passed owner JID is stripped + of the resource (e.g. using C{owner.userhostJID()}). + + @param entity: JID of the entity. + @type entity: L{JID} + @return: deferred that returns a C{list} of tuples of the form + C{(nodeIdentifier, subscriber, state)}, where + C{nodeIdentifier} is of the type C{unicode}, C{subscriber} of + the type J{JID}, and + C{state} is C{'subscribed'}, C{'pending'} or + C{'unconfigured'}. + """ + + + def getDefaultConfiguration(nodeType): + """ + Get the default configuration for the given node type. + + @param nodeType: Either C{'leaf'} or C{'collection'}. + @type nodeType: C{str} + @return: The default configuration. + @rtype: C{dict}. + @raises: L{idavoll.error.NoCollections} if collections are not + supported. + """ + + + +class INode(Interface): + """ + Interface to the class of objects that represent nodes. + """ + + nodeType = Attribute("""The type of this node. One of {'leaf'}, + {'collection'}.""") + nodeIdentifier = Attribute("""The node identifer of this node""") + + + def getType(): + """ + Get node's type. + + @return: C{'leaf'} or C{'collection'}. + """ + + + def getConfiguration(): + """ + Get node's configuration. + + The configuration must at least have two options: + C{pubsub#persist_items}, and C{pubsub#deliver_payloads}. + + @return: C{dict} of configuration options. + """ + + + def getMetaData(): + """ + Get node's meta data. + + The meta data must be a superset of the configuration options, and + also at least should have a C{pubsub#node_type} entry. + + @return: C{dict} of meta data. + """ + + + def setConfiguration(options): + """ + Set node's configuration. + + The elements of {options} will set the new values for those + configuration items. This means that only changing items have to + be given. + + @param options: a dictionary of configuration options. + @returns: a deferred that fires upon success. + """ + + + def getAffiliation(entity): + """ + Get affiliation of entity with this node. + + @param entity: JID of entity. + @type entity: L{JID} + @return: deferred that returns C{'owner'}, C{'publisher'}, C{'outcast'} + or C{None}. + """ + + + def getSubscription(subscriber): + """ + Get subscription to this node of subscriber. + + @param subscriber: JID of the new subscriptions' entity. + @type subscriber: L{JID} + @return: deferred that returns the subscription state (C{'subscribed'}, + C{'pending'} or C{None}). + """ + + + def getSubscriptions(state=None): + """ + Get list of subscriptions to this node. + + The optional C{state} argument filters the subscriptions to their + state. + + @param state: Subscription state filter. One of C{'subscribed'}, + C{'pending'}, C{'unconfigured'}. + @type state: C{str} + @return: a deferred that returns a C{list} of + L{wokkel.pubsub.Subscription}s. + """ + + + def addSubscription(subscriber, state, config): + """ + Add new subscription to this node with given state. + + @param subscriber: JID of the new subscriptions' entity. + @type subscriber: L{JID} + @param state: C{'subscribed'} or C{'pending'} + @type state: C{str} + @param config: Subscription configuration. + @param config: C{dict} + @return: deferred that fires on subscription. + """ + + + def removeSubscription(subscriber): + """ + Remove subscription to this node. + + @param subscriber: JID of the subscriptions' entity. + @type subscriber: L{JID} + @return: deferred that fires on removal. + """ + + + def isSubscribed(entity): + """ + Returns whether entity has any subscription to this node. + + Only returns C{True} when the subscription state (if present) is + C{'subscribed'} for any subscription that matches the bare JID. + + @param subscriber: bare JID of the subscriptions' entity. + @type subscriber: L{JID} + @return: deferred that returns a C{bool}. + """ + + + def getAffiliations(): + """ + Get affiliations of entities with this node. + + @return: deferred that returns a C{list} of tuples (jid, affiliation), + where jid is a L(JID) + and affiliation is one of C{'owner'}, + C{'publisher'}, C{'outcast'}. + """ + + + +class ILeafNode(Interface): + """ + Interface to the class of objects that represent leaf nodes. + """ + + def storeItems(items, publisher): + """ + Store items in persistent storage for later retrieval. + + @param items: The list of items to be stored. Each item is the + L{domish} representation of the XML fragment as defined + for C{} in the + C{http://jabber.org/protocol/pubsub} namespace. + @type items: C{list} of {domish.Element} + @param publisher: JID of the publishing entity. + @type publisher: L{JID} + @return: deferred that fires upon success. + """ + + + def removeItems(itemIdentifiers): + """ + Remove items by id. + + @param itemIdentifiers: C{list} of item ids. + @return: deferred that fires with a C{list} of ids of the items that + were deleted + """ + + + def getItems(authorized_groups, unrestricted, maxItems=None): + """ Get all authorised items + If C{maxItems} is not given, all authorised items in the node are returned, + just like C{getItemsById}. Otherwise, C{maxItems} limits + the returned items to a maximum of that number of most recently + published and authorised items. + + @param authorized_groups: we want to get items that these groups can access + @param unrestricted: if true, don't check permissions (i.e.: get all items) + @param maxItems: if given, a natural number (>0) that limits the + returned number of items. + @return: deferred that fires a C{list} of (item, access_model, id) + if unrestricted is True, else a C{list} of items. + """ + + + def countItems(authorized_groups, unrestricted): + """ Count the accessible items. + + @param authorized_groups: we want to get items that these groups can access. + @param unrestricted: if true, don't check permissions (i.e.: get all items). + @return: deferred that fires a C{int}. + """ + + + def getIndex(authorized_groups, unrestricted, item): + """ Retrieve the index of the given item within the accessible window. + + @param authorized_groups: we want to get items that these groups can access. + @param unrestricted: if true, don't check permissions (i.e.: get all items). + @param item: item identifier. + @return: deferred that fires a C{int}. + """ + + def getItemsById(authorized_groups, unrestricted, itemIdentifiers): + """ + Get items by item id. + + Each item in the returned list is a unicode string that + represent the XML of the item as it was published, including the + item wrapper with item id. + + @param authorized_groups: we want to get items that these groups can access + @param unrestricted: if true, don't check permissions + @param itemIdentifiers: C{list} of item ids. + @return: deferred that fires a C{list} of (item, access_model, id) + if unrestricted is True, else a C{list} of items. + """ + + + def purge(): + """ + Purge node of all items in persistent storage. + + @return: deferred that fires when the node has been purged. + """ + + + def filterItemsWithPublisher(itemIdentifiers, requestor): + """ + Filter the given items by checking the items publisher against the requestor. + + @param itemIdentifiers: C{list} of item ids. + @param requestor: JID of the requestor. + @type requestor: L{JID} + @return: deferred that fires with a C{list} of item identifiers. + """ + +class IGatewayStorage(Interface): + + def addCallback(service, nodeIdentifier, callback): + """ + Register a callback URI. + + The registered HTTP callback URI will have an Atom Entry documented + POSTed to it upon receiving a notification for the given pubsub node. + + @param service: The XMPP entity that holds the node. + @type service: L{JID} + @param nodeIdentifier: The identifier of the publish-subscribe node. + @type nodeIdentifier: C{unicode}. + @param callback: The callback URI to be registered. + @type callback: C{str}. + @rtype: L{Deferred} + """ + + def removeCallback(service, nodeIdentifier, callback): + """ + Remove a registered callback URI. + + The returned deferred will fire with a boolean that signals wether or + not this was the last callback unregistered for this node. + + @param service: The XMPP entity that holds the node. + @type service: L{JID} + @param nodeIdentifier: The identifier of the publish-subscribe node. + @type nodeIdentifier: C{unicode}. + @param callback: The callback URI to be unregistered. + @type callback: C{str}. + @rtype: L{Deferred} + """ + + def getCallbacks(service, nodeIdentifier): + """ + Get the callbacks registered for this node. + + Returns a deferred that fires with the set of HTTP callback URIs + registered for this node. + + @param service: The XMPP entity that holds the node. + @type service: L{JID} + @param nodeIdentifier: The identifier of the publish-subscribe node. + @type nodeIdentifier: C{unicode}. + @rtype: L{Deferred} + """ + + + def hasCallbacks(service, nodeIdentifier): + """ + Return wether there are callbacks registered for a node. + + @param service: The XMPP entity that holds the node. + @type service: L{JID} + @param nodeIdentifier: The identifier of the publish-subscribe node. + @type nodeIdentifier: C{unicode}. + @returns: Deferred that fires with a boolean. + @rtype: L{Deferred} + """ diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/mam.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/mam.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,181 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2016 Jérôme Poisson +# Copyright (c) 2015-2016 Adrien Cossa +# +# 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 . + +""" +XMPP Message Archive Management protocol. + +This protocol is specified in +U{XEP-0313}. +""" + + +from zope.interface import implements + +from twisted.words.xish import domish +from twisted.python import log +from twisted.words.protocols.jabber import error + +from sat_pubsub import const +from sat_pubsub import backend +from wokkel import pubsub + +from wokkel import rsm +from wokkel import mam +from wokkel import delay + + +class MAMResource(object): + implements(mam.IMAMResource) + _errorMap = backend.PubSubResourceFromBackend._errorMap + + def __init__(self, backend_): + self.backend = backend_ + + def _mapErrors(self, failure): + # XXX: come from backend.PubsubResourceFromBackend + e = failure.trap(*self._errorMap.keys()) + + condition, pubsubCondition, feature = self._errorMap[e] + msg = failure.value.msg + + if pubsubCondition: + exc = pubsub.PubSubError(condition, pubsubCondition, feature, msg) + else: + exc = error.StanzaError(condition, text=msg) + + raise exc + + def onArchiveRequest(self, mam_request): + """ + + @param mam_request: The MAM archive request. + @type mam_request: L{MAMQueryReques} + + @return: A tuple with list of message data (id, element, data) and RSM element + @rtype: C{tuple} + """ + # FIXME: bad result ordering + try: + pep = mam_request.delegated + except AttributeError: + pep = False + ext_data = {'pep': pep} + if mam_request.form: + ext_data['filters'] = mam_request.form.fields.values() + if mam_request.rsm is None: + if const.VAL_RSM_MAX_DEFAULT != None: + log.msg("MAM request without RSM limited to {}".format(const.VAL_RSM_MAX_DEFAULT)) + ext_data['rsm'] = rsm.RSMRequest(const.VAL_RSM_MAX_DEFAULT) + else: + ext_data['rsm'] = mam_request.rsm + + if mam_request.orderBy: + ext_data['order_by'] = mam_request.orderBy + + d = self.backend.getItemsData(mam_request.node, mam_request.sender, + mam_request.recipient, None, None, ext_data) + + def make_message(elt): + # XXX: http://xmpp.org/extensions/xep-0297.html#sect-idp629952 (rule 3) + message = domish.Element((const.NS_CLIENT, "message")) + event = message.addElement((pubsub.NS_PUBSUB_EVENT, "event")) + items = event.addElement('items') + items["node"] = mam_request.node + items.addChild(elt) + return message + + def cb(items_data): + msg_data = [] + rsm_elt = None + attributes = {} + for item_data in items_data: + if item_data.item.name == 'set' and item_data.item.uri == rsm.NS_RSM: + assert rsm_elt is None + rsm_elt = item_data.item + if rsm_elt.first: + # XXX: we check if it is the last page using initial request data + # and RSM element data. In this case, we must have the + # "complete" + # attribute set to "true". + page_max = (int(rsm_elt.first['index']) + 1) * mam_request.rsm.max + count = int(unicode(rsm_elt.count)) + if page_max >= count: + # the maximum items which can be displayed is equal to or + # above the total number of items, which means we are complete + attributes['complete'] = "true" + else: + log.msg("WARNING: no element in RSM request: {xml}".format( + xml = rsm_elt.toXml().encode('utf-8'))) + elif item_data.item.name == 'item': + msg_data.append([item_data.item['id'], make_message(item_data.item), + item_data.created]) + else: + log.msg("WARNING: unknown element: {}".format(item_data.item.name)) + if pep: + # we need to send privileged message + # so me manage the sending ourself, and return + # an empty msg_data list to avoid double sending + for data in msg_data: + self.forwardPEPMessage(mam_request, *data) + msg_data = [] + return (msg_data, rsm_elt, attributes) + + d.addErrback(self._mapErrors) + d.addCallback(cb) + return d + + def forwardPEPMessage(self, mam_request, id_, elt, created): + msg = domish.Element((None, 'message')) + msg['from'] = self.backend.privilege.server_jid.full() + msg['to'] = mam_request.sender.full() + result = msg.addElement((mam.NS_MAM, 'result')) + if mam_request.query_id is not None: + result['queryid'] = mam_request.query_id + result['id'] = id_ + forward = result.addElement((const.NS_FORWARD, 'forwarded')) + forward.addChild(delay.Delay(created).toElement()) + forward.addChild(elt) + self.backend.privilege.sendMessage(msg) + + def onPrefsGetRequest(self, requestor): + """ + + @param requestor: JID of the requestor. + @type requestor: L{JID} + + @return: The current settings. + @rtype: L{wokkel.mam.MAMPrefs} + """ + # TODO: return the actual current settings + return mam.MAMPrefs() + + def onPrefsSetRequest(self, prefs, requestor): + """ + + @param prefs: The new settings to set. + @type prefs: L{wokkel.mam.MAMPrefs} + + @param requestor: JID of the requestor. + @type requestor: L{JID} + + @return: The settings that have actually been set. + @rtype: L{wokkel.mam.MAMPrefs} + """ + # TODO: set the new settings and return them + return mam.MAMPrefs() diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/memory_storage.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/memory_storage.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,380 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2019 Jérôme Poisson + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +import copy +from zope.interface import implements +from twisted.internet import defer +from twisted.words.protocols.jabber import jid + +from wokkel.pubsub import Subscription + +from sat_pubsub import error, iidavoll + +class Storage: + + implements(iidavoll.IStorage) + + defaultConfig = { + 'leaf': { + "pubsub#persist_items": True, + "pubsub#deliver_payloads": True, + "pubsub#send_last_published_item": 'on_sub', + }, + 'collection': { + "pubsub#deliver_payloads": True, + "pubsub#send_last_published_item": 'on_sub', + } + } + + def __init__(self): + rootNode = CollectionNode('', jid.JID('localhost'), + copy.copy(self.defaultConfig['collection'])) + self._nodes = {'': rootNode} + + + def getNode(self, nodeIdentifier): + try: + node = self._nodes[nodeIdentifier] + except KeyError: + return defer.fail(error.NodeNotFound()) + + return defer.succeed(node) + + + def getNodeIds(self): + return defer.succeed(self._nodes.keys()) + + + def createNode(self, nodeIdentifier, owner, config): + if nodeIdentifier in self._nodes: + return defer.fail(error.NodeExists()) + + if config['pubsub#node_type'] != 'leaf': + raise error.NoCollections() + + node = LeafNode(nodeIdentifier, owner, config) + self._nodes[nodeIdentifier] = node + + return defer.succeed(None) + + + def deleteNode(self, nodeIdentifier): + try: + del self._nodes[nodeIdentifier] + except KeyError: + return defer.fail(error.NodeNotFound()) + + return defer.succeed(None) + + + def getAffiliations(self, entity): + entity = entity.userhost() + return defer.succeed([(node.nodeIdentifier, node._affiliations[entity]) + for name, node in self._nodes.iteritems() + if entity in node._affiliations]) + + + def getSubscriptions(self, entity): + subscriptions = [] + for node in self._nodes.itervalues(): + for subscriber, subscription in node._subscriptions.iteritems(): + subscriber = jid.internJID(subscriber) + if subscriber.userhostJID() == entity.userhostJID(): + subscriptions.append(subscription) + + return defer.succeed(subscriptions) + + + def getDefaultConfiguration(self, nodeType): + if nodeType == 'collection': + raise error.NoCollections() + + return self.defaultConfig[nodeType] + + +class Node: + + implements(iidavoll.INode) + + def __init__(self, nodeIdentifier, owner, config): + self.nodeIdentifier = nodeIdentifier + self._affiliations = {owner.userhost(): 'owner'} + self._subscriptions = {} + self._config = copy.copy(config) + + + def getType(self): + return self.nodeType + + + def getConfiguration(self): + return self._config + + + def getMetaData(self): + config = copy.copy(self._config) + config["pubsub#node_type"] = self.nodeType + return config + + + def setConfiguration(self, options): + for option in options: + if option in self._config: + self._config[option] = options[option] + + return defer.succeed(None) + + + def getAffiliation(self, entity): + return defer.succeed(self._affiliations.get(entity.userhost())) + + + def getSubscription(self, subscriber): + try: + subscription = self._subscriptions[subscriber.full()] + except KeyError: + return defer.succeed(None) + else: + return defer.succeed(subscription) + + + def getSubscriptions(self, state=None): + return defer.succeed( + [subscription + for subscription in self._subscriptions.itervalues() + if state is None or subscription.state == state]) + + + + def addSubscription(self, subscriber, state, options): + if self._subscriptions.get(subscriber.full()): + return defer.fail(error.SubscriptionExists()) + + subscription = Subscription(self.nodeIdentifier, subscriber, state, + options) + self._subscriptions[subscriber.full()] = subscription + return defer.succeed(None) + + + def removeSubscription(self, subscriber): + try: + del self._subscriptions[subscriber.full()] + except KeyError: + return defer.fail(error.NotSubscribed()) + + return defer.succeed(None) + + + def isSubscribed(self, entity): + for subscriber, subscription in self._subscriptions.iteritems(): + if jid.internJID(subscriber).userhost() == entity.userhost() and \ + subscription.state == 'subscribed': + return defer.succeed(True) + + return defer.succeed(False) + + + def getAffiliations(self): + affiliations = [(jid.internJID(entity), affiliation) for entity, affiliation + in self._affiliations.iteritems()] + + return defer.succeed(affiliations) + + + +class PublishedItem(object): + """ + A published item. + + This represent an item as it was published by an entity. + + @ivar element: The DOM representation of the item that was published. + @type element: L{Element} + @ivar publisher: The entity that published the item. + @type publisher: L{JID} + """ + + def __init__(self, element, publisher): + self.element = element + self.publisher = publisher + + + +class LeafNode(Node): + + implements(iidavoll.ILeafNode) + + nodeType = 'leaf' + + def __init__(self, nodeIdentifier, owner, config): + Node.__init__(self, nodeIdentifier, owner, config) + self._items = {} + self._itemlist = [] + + + def storeItems(self, item_data, publisher): + for access_model, item_config, element in item_data: + item = PublishedItem(element, publisher) + itemIdentifier = element["id"] + if itemIdentifier in self._items: + self._itemlist.remove(self._items[itemIdentifier]) + self._items[itemIdentifier] = item + self._itemlist.append(item) + + return defer.succeed(None) + + + def removeItems(self, itemIdentifiers): + deleted = [] + + for itemIdentifier in itemIdentifiers: + try: + item = self._items[itemIdentifier] + except KeyError: + pass + else: + self._itemlist.remove(item) + del self._items[itemIdentifier] + deleted.append(itemIdentifier) + + return defer.succeed(deleted) + + + def getItems(self, authorized_groups, unrestricted, maxItems=None): + if maxItems is not None: + itemList = self._itemlist[-maxItems:] + else: + itemList = self._itemlist + return defer.succeed([item.element for item in itemList]) + + + def getItemsById(self, authorized_groups, unrestricted, itemIdentifiers): + items = [] + for itemIdentifier in itemIdentifiers: + try: + item = self._items[itemIdentifier] + except KeyError: + pass + else: + items.append(item.element) + return defer.succeed(items) + + + def purge(self): + self._items = {} + self._itemlist = [] + + return defer.succeed(None) + + + def filterItemsWithPublisher(self, itemIdentifiers, requestor): + filteredItems = [] + for itemIdentifier in itemIdentifiers: + try: + if self._items[itemIdentifier].publisher.userhost() == requestor.userhost(): + filteredItems.append(self.items[itemIdentifier]) + except KeyError, AttributeError: + pass + return defer.succeed(filteredItems) + + +class CollectionNode(Node): + nodeType = 'collection' + + + +class GatewayStorage(object): + """ + Memory based storage facility for the XMPP-HTTP gateway. + """ + + def __init__(self): + self.callbacks = {} + + + def addCallback(self, service, nodeIdentifier, callback): + try: + callbacks = self.callbacks[service, nodeIdentifier] + except KeyError: + callbacks = {callback} + self.callbacks[service, nodeIdentifier] = callbacks + else: + callbacks.add(callback) + pass + + return defer.succeed(None) + + + def removeCallback(self, service, nodeIdentifier, callback): + try: + callbacks = self.callbacks[service, nodeIdentifier] + callbacks.remove(callback) + except KeyError: + return defer.fail(error.NotSubscribed()) + else: + if not callbacks: + del self.callbacks[service, nodeIdentifier] + + return defer.succeed(not callbacks) + + + def getCallbacks(self, service, nodeIdentifier): + try: + callbacks = self.callbacks[service, nodeIdentifier] + except KeyError: + return defer.fail(error.NoCallbacks()) + else: + return defer.succeed(callbacks) + + + def hasCallbacks(self, service, nodeIdentifier): + return defer.succeed((service, nodeIdentifier) in self.callbacks) diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/pgsql_storage.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/pgsql_storage.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,1379 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2019 Jérôme Poisson +# Copyright (c) 2013-2016 Adrien Cossa +# Copyright (c) 2003-2011 Ralph Meijer + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +import copy, logging + +from zope.interface import implements + +from twisted.internet import reactor +from twisted.internet import defer +from twisted.words.protocols.jabber import jid +from twisted.python import log + +from wokkel import generic +from wokkel.pubsub import Subscription + +from sat_pubsub import error +from sat_pubsub import iidavoll +from sat_pubsub import const +from sat_pubsub import container +from sat_pubsub import exceptions +import uuid +import psycopg2 +import psycopg2.extensions +# we wants psycopg2 to return us unicode, not str +psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) +psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY) + +# parseXml manage str, but we get unicode +parseXml = lambda unicode_data: generic.parseXml(unicode_data.encode('utf-8')) +ITEMS_SEQ_NAME = u'node_{node_id}_seq' +PEP_COL_NAME = 'pep' +CURRENT_VERSION = '5' +# retrieve the maximum integer item id + 1 +NEXT_ITEM_ID_QUERY = r"SELECT COALESCE(max(item::integer)+1,1) as val from items where node_id={node_id} and item ~ E'^\\d+$'" + + +def withPEP(query, values, pep, recipient): + """Helper method to facilitate PEP management + + @param query: SQL query basis + @param values: current values to replace in query + @param pep(bool): True if we are in PEP mode + @param recipient(jid.JID): jid of the recipient + @return: query + PEP AND check, + recipient's bare jid is added to value if needed + """ + if pep: + pep_check="AND {}=%s".format(PEP_COL_NAME) + values=list(values) + [recipient.userhost()] + else: + pep_check="AND {} IS NULL".format(PEP_COL_NAME) + return "{} {}".format(query, pep_check), values + + +class Storage: + + implements(iidavoll.IStorage) + + defaultConfig = { + 'leaf': { + const.OPT_PERSIST_ITEMS: True, + const.OPT_DELIVER_PAYLOADS: True, + const.OPT_SEND_LAST_PUBLISHED_ITEM: 'on_sub', + const.OPT_ACCESS_MODEL: const.VAL_AMODEL_DEFAULT, + const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_DEFAULT, + const.OPT_SERIAL_IDS: False, + const.OPT_CONSISTENT_PUBLISHER: False, + }, + 'collection': { + const.OPT_DELIVER_PAYLOADS: True, + const.OPT_SEND_LAST_PUBLISHED_ITEM: 'on_sub', + const.OPT_ACCESS_MODEL: const.VAL_AMODEL_DEFAULT, + const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_DEFAULT, + } + } + + def __init__(self, dbpool): + self.dbpool = dbpool + d = self.dbpool.runQuery("SELECT value FROM metadata WHERE key='version'") + d.addCallbacks(self._checkVersion, self._versionEb) + + def _checkVersion(self, row): + version = row[0].value + if version != CURRENT_VERSION: + logging.error("Bad database schema version ({current}), please upgrade to {needed}".format( + current=version, needed=CURRENT_VERSION)) + reactor.stop() + + def _versionEb(self, failure): + logging.error("Can't check schema version: {reason}".format(reason=failure)) + reactor.stop() + + def _buildNode(self, row): + """Build a note class from database result row""" + configuration = {} + + if not row: + raise error.NodeNotFound() + + if row[2] == 'leaf': + configuration = { + 'pubsub#persist_items': row[3], + 'pubsub#deliver_payloads': row[4], + 'pubsub#send_last_published_item': row[5], + const.OPT_ACCESS_MODEL:row[6], + const.OPT_PUBLISH_MODEL:row[7], + const.OPT_SERIAL_IDS:row[8], + const.OPT_CONSISTENT_PUBLISHER:row[9], + } + schema = row[10] + if schema is not None: + schema = parseXml(schema) + node = LeafNode(row[0], row[1], configuration, schema) + node.dbpool = self.dbpool + return node + elif row[2] == 'collection': + configuration = { + 'pubsub#deliver_payloads': row[4], + 'pubsub#send_last_published_item': row[5], + const.OPT_ACCESS_MODEL: row[6], + const.OPT_PUBLISH_MODEL:row[7], + } + node = CollectionNode(row[0], row[1], configuration, None) + node.dbpool = self.dbpool + return node + else: + raise ValueError("Unknown node type !") + + def getNodeById(self, nodeDbId): + """Get node using database ID insted of pubsub identifier + + @param nodeDbId(unicode): database ID + """ + return self.dbpool.runInteraction(self._getNodeById, nodeDbId) + + def _getNodeById(self, cursor, nodeDbId): + cursor.execute("""SELECT node_id, + node, + node_type, + persist_items, + deliver_payloads, + send_last_published_item, + access_model, + publish_model, + serial_ids, + consistent_publisher, + schema::text, + pep + FROM nodes + WHERE node_id=%s""", + (nodeDbId,)) + row = cursor.fetchone() + return self._buildNode(row) + + def getNode(self, nodeIdentifier, pep, recipient=None): + return self.dbpool.runInteraction(self._getNode, nodeIdentifier, pep, recipient) + + def _getNode(self, cursor, nodeIdentifier, pep, recipient): + cursor.execute(*withPEP("""SELECT node_id, + node, + node_type, + persist_items, + deliver_payloads, + send_last_published_item, + access_model, + publish_model, + serial_ids, + consistent_publisher, + schema::text, + pep + FROM nodes + WHERE node=%s""", + (nodeIdentifier,), pep, recipient)) + row = cursor.fetchone() + return self._buildNode(row) + + def getNodeIds(self, pep, recipient, allowed_accesses=None): + """retrieve ids of existing nodes + + @param pep(bool): True if it's a PEP request + @param recipient(jid.JID, None): recipient of the PEP request + @param allowed_accesses(None, set): only nodes with access + in this set will be returned + None to return all nodes + @return (list[unicode]): ids of nodes + """ + if not pep: + query = "SELECT node from nodes WHERE pep is NULL" + values = [] + else: + query = "SELECT node from nodes WHERE pep=%s" + values = [recipient.userhost()] + + if allowed_accesses is not None: + query += "AND access_model IN %s" + values.append(tuple(allowed_accesses)) + + d = self.dbpool.runQuery(query, values) + d.addCallback(lambda results: [r[0] for r in results]) + return d + + def createNode(self, nodeIdentifier, owner, config, schema, pep, recipient=None): + return self.dbpool.runInteraction(self._createNode, nodeIdentifier, + owner, config, schema, pep, recipient) + + def _createNode(self, cursor, nodeIdentifier, owner, config, schema, pep, recipient): + if config['pubsub#node_type'] != 'leaf': + raise error.NoCollections() + + owner = owner.userhost() + + try: + cursor.execute("""INSERT INTO nodes + (node, + node_type, + persist_items, + deliver_payloads, + send_last_published_item, + access_model, + publish_model, + serial_ids, + consistent_publisher, + schema, + pep) + VALUES + (%s, 'leaf', %s, %s, %s, %s, %s, %s, %s, %s, %s)""", + (nodeIdentifier, + config['pubsub#persist_items'], + config['pubsub#deliver_payloads'], + config['pubsub#send_last_published_item'], + config[const.OPT_ACCESS_MODEL], + config[const.OPT_PUBLISH_MODEL], + config[const.OPT_SERIAL_IDS], + config[const.OPT_CONSISTENT_PUBLISHER], + schema, + recipient.userhost() if pep else None + ) + ) + except cursor._pool.dbapi.IntegrityError as e: + if e.pgcode == "23505": + # unique_violation + raise error.NodeExists() + else: + raise error.InvalidConfigurationOption() + + cursor.execute(*withPEP("""SELECT node_id FROM nodes WHERE node=%s""", + (nodeIdentifier,), pep, recipient)); + node_id = cursor.fetchone()[0] + + cursor.execute("""SELECT 1 as bool from entities where jid=%s""", + (owner,)) + + if not cursor.fetchone(): + # XXX: we can NOT rely on the previous query! Commit is needed now because + # if the entry exists the next query will leave the database in a corrupted + # state: the solution is to rollback. I tried with other methods like + # "WHERE NOT EXISTS" but none of them worked, so the following solution + # looks like the sole - unless you have auto-commit on. More info + # about this issue: http://cssmay.com/question/tag/tag-psycopg2 + cursor.connection.commit() + try: + cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", + (owner,)) + except psycopg2.IntegrityError as e: + cursor.connection.rollback() + logging.warning("during node creation: %s" % e.message) + + cursor.execute("""INSERT INTO affiliations + (node_id, entity_id, affiliation) + SELECT %s, entity_id, 'owner' FROM + (SELECT entity_id FROM entities + WHERE jid=%s) as e""", + (node_id, owner)) + + if config[const.OPT_ACCESS_MODEL] == const.VAL_AMODEL_PUBLISHER_ROSTER: + if const.OPT_ROSTER_GROUPS_ALLOWED in config: + allowed_groups = config[const.OPT_ROSTER_GROUPS_ALLOWED] + else: + allowed_groups = [] + for group in allowed_groups: + #TODO: check that group are actually in roster + cursor.execute("""INSERT INTO node_groups_authorized (node_id, groupname) + VALUES (%s,%s)""" , (node_id, group)) + # XXX: affiliations can't be set on during node creation (at least not with XEP-0060 alone) + # so whitelist affiliations need to be done afterward + + # no we may have to do extra things according to config options + default_conf = self.defaultConfig['leaf'] + # XXX: trigger works on node creation because OPT_SERIAL_IDS is False in defaultConfig + # if this value is changed, the _configurationTriggers method should be adapted. + Node._configurationTriggers(cursor, node_id, default_conf, config) + + def deleteNodeByDbId(self, db_id): + """Delete a node using directly its database id""" + return self.dbpool.runInteraction(self._deleteNodeByDbId, db_id) + + def _deleteNodeByDbId(self, cursor, db_id): + cursor.execute("""DELETE FROM nodes WHERE node_id=%s""", + (db_id,)) + + if cursor.rowcount != 1: + raise error.NodeNotFound() + + def deleteNode(self, nodeIdentifier, pep, recipient=None): + return self.dbpool.runInteraction(self._deleteNode, nodeIdentifier, pep, recipient) + + def _deleteNode(self, cursor, nodeIdentifier, pep, recipient): + cursor.execute(*withPEP("""DELETE FROM nodes WHERE node=%s""", + (nodeIdentifier,), pep, recipient)) + + if cursor.rowcount != 1: + raise error.NodeNotFound() + + def getAffiliations(self, entity, nodeIdentifier, pep, recipient=None): + return self.dbpool.runInteraction(self._getAffiliations, entity, nodeIdentifier, pep, recipient) + + def _getAffiliations(self, cursor, entity, nodeIdentifier, pep, recipient=None): + query = ["""SELECT node, affiliation FROM entities + NATURAL JOIN affiliations + NATURAL JOIN nodes + WHERE jid=%s"""] + args = [entity.userhost()] + + if nodeIdentifier is not None: + query.append("AND node=%s") + args.append(nodeIdentifier) + + cursor.execute(*withPEP(' '.join(query), args, pep, recipient)) + rows = cursor.fetchall() + return [tuple(r) for r in rows] + + def getSubscriptions(self, entity, nodeIdentifier=None, pep=False, recipient=None): + """retrieve subscriptions of an entity + + @param entity(jid.JID): entity to check + @param nodeIdentifier(unicode, None): node identifier + None to retrieve all subscriptions + @param pep: True if we are in PEP mode + @param recipient: jid of the recipient + """ + + def toSubscriptions(rows): + subscriptions = [] + for row in rows: + subscriber = jid.internJID('%s/%s' % (row.jid, + row.resource)) + subscription = Subscription(row.node, subscriber, row.state) + subscriptions.append(subscription) + return subscriptions + + query = ["""SELECT node, + jid, + resource, + state + FROM entities + NATURAL JOIN subscriptions + NATURAL JOIN nodes + WHERE jid=%s"""] + + args = [entity.userhost()] + + if nodeIdentifier is not None: + query.append("AND node=%s") + args.append(nodeIdentifier) + + d = self.dbpool.runQuery(*withPEP(' '.join(query), args, pep, recipient)) + d.addCallback(toSubscriptions) + return d + + def getDefaultConfiguration(self, nodeType): + return self.defaultConfig[nodeType].copy() + + def formatLastItems(self, result): + last_items = [] + for pep_jid_s, node, data, item_access_model in result: + pep_jid = jid.JID(pep_jid_s) + item = generic.stripNamespace(parseXml(data)) + last_items.append((pep_jid, node, item, item_access_model)) + return last_items + + def getLastItems(self, entities, nodes, node_accesses, item_accesses, pep): + """get last item for several nodes and entities in a single request""" + if not entities or not nodes or not node_accesses or not item_accesses: + raise ValueError("entities, nodes and accesses must not be empty") + if node_accesses != ('open',) or item_accesses != ('open',): + raise NotImplementedError('only "open" access model is handled for now') + if not pep: + raise NotImplementedError(u"getLastItems is only implemented for PEP at the moment") + d = self.dbpool.runQuery("""SELECT DISTINCT ON (node_id) pep, node, data::text, items.access_model + FROM items + NATURAL JOIN nodes + WHERE nodes.pep IN %s + AND node IN %s + AND nodes.access_model in %s + AND items.access_model in %s + ORDER BY node_id DESC, items.updated DESC""", + (tuple([e.userhost() for e in entities]), + nodes, + node_accesses, + item_accesses)) + d.addCallback(self.formatLastItems) + return d + + +class Node: + + implements(iidavoll.INode) + + def __init__(self, nodeDbId, nodeIdentifier, config, schema): + self.nodeDbId = nodeDbId + self.nodeIdentifier = nodeIdentifier + self._config = config + self._schema = schema + + def _checkNodeExists(self, cursor): + cursor.execute("""SELECT 1 as exist FROM nodes WHERE node_id=%s""", + (self.nodeDbId,)) + if not cursor.fetchone(): + raise error.NodeNotFound() + + def getType(self): + return self.nodeType + + def getOwners(self): + d = self.dbpool.runQuery("""SELECT jid FROM nodes NATURAL JOIN affiliations NATURAL JOIN entities WHERE node_id=%s and affiliation='owner'""", (self.nodeDbId,)) + d.addCallback(lambda rows: [jid.JID(r[0]) for r in rows]) + return d + + def getConfiguration(self): + return self._config + + def getNextId(self): + """return XMPP item id usable for next item to publish + + the return value will be next int if serila_ids is set, + else an UUID will be returned + """ + if self._config[const.OPT_SERIAL_IDS]: + d = self.dbpool.runQuery("SELECT nextval('{seq_name}')".format( + seq_name = ITEMS_SEQ_NAME.format(node_id=self.nodeDbId))) + d.addCallback(lambda rows: unicode(rows[0][0])) + return d + else: + return defer.succeed(unicode(uuid.uuid4())) + + @staticmethod + def _configurationTriggers(cursor, node_id, old_config, new_config): + """trigger database relative actions needed when a config is changed + + @param cursor(): current db cursor + @param node_id(unicode): database ID of the node + @param old_config(dict): config of the node before the change + @param new_config(dict): new options that will be changed + """ + serial_ids = new_config[const.OPT_SERIAL_IDS] + if serial_ids != old_config[const.OPT_SERIAL_IDS]: + # serial_ids option has been modified, + # we need to handle corresponding sequence + + # XXX: we use .format in following queries because values + # are generated by ourself + seq_name = ITEMS_SEQ_NAME.format(node_id=node_id) + if serial_ids: + # the next query get the max value +1 of all XMPP items ids + # which are integers, and default to 1 + cursor.execute(NEXT_ITEM_ID_QUERY.format(node_id=node_id)) + next_val = cursor.fetchone()[0] + cursor.execute("DROP SEQUENCE IF EXISTS {seq_name}".format(seq_name = seq_name)) + cursor.execute("CREATE SEQUENCE {seq_name} START {next_val} OWNED BY nodes.node_id".format( + seq_name = seq_name, + next_val = next_val)) + else: + cursor.execute("DROP SEQUENCE IF EXISTS {seq_name}".format(seq_name = seq_name)) + + def setConfiguration(self, options): + config = copy.copy(self._config) + + for option in options: + if option in config: + config[option] = options[option] + + d = self.dbpool.runInteraction(self._setConfiguration, config) + d.addCallback(self._setCachedConfiguration, config) + return d + + def _setConfiguration(self, cursor, config): + self._checkNodeExists(cursor) + self._configurationTriggers(cursor, self.nodeDbId, self._config, config) + cursor.execute("""UPDATE nodes SET persist_items=%s, + deliver_payloads=%s, + send_last_published_item=%s, + access_model=%s, + publish_model=%s, + serial_ids=%s, + consistent_publisher=%s + WHERE node_id=%s""", + (config[const.OPT_PERSIST_ITEMS], + config[const.OPT_DELIVER_PAYLOADS], + config[const.OPT_SEND_LAST_PUBLISHED_ITEM], + config[const.OPT_ACCESS_MODEL], + config[const.OPT_PUBLISH_MODEL], + config[const.OPT_SERIAL_IDS], + config[const.OPT_CONSISTENT_PUBLISHER], + self.nodeDbId)) + + def _setCachedConfiguration(self, void, config): + self._config = config + + def getSchema(self): + return self._schema + + def setSchema(self, schema): + d = self.dbpool.runInteraction(self._setSchema, schema) + d.addCallback(self._setCachedSchema, schema) + return d + + def _setSchema(self, cursor, schema): + self._checkNodeExists(cursor) + cursor.execute("""UPDATE nodes SET schema=%s + WHERE node_id=%s""", + (schema.toXml() if schema else None, + self.nodeDbId)) + + def _setCachedSchema(self, void, schema): + self._schema = schema + + def getMetaData(self): + config = copy.copy(self._config) + config["pubsub#node_type"] = self.nodeType + return config + + def getAffiliation(self, entity): + return self.dbpool.runInteraction(self._getAffiliation, entity) + + def _getAffiliation(self, cursor, entity): + self._checkNodeExists(cursor) + cursor.execute("""SELECT affiliation FROM affiliations + NATURAL JOIN nodes + NATURAL JOIN entities + WHERE node_id=%s AND jid=%s""", + (self.nodeDbId, + entity.userhost())) + + try: + return cursor.fetchone()[0] + except TypeError: + return None + + def getAccessModel(self): + return self._config[const.OPT_ACCESS_MODEL] + + def getSubscription(self, subscriber): + return self.dbpool.runInteraction(self._getSubscription, subscriber) + + def _getSubscription(self, cursor, subscriber): + self._checkNodeExists(cursor) + + userhost = subscriber.userhost() + resource = subscriber.resource or '' + + cursor.execute("""SELECT state FROM subscriptions + NATURAL JOIN nodes + NATURAL JOIN entities + WHERE node_id=%s AND jid=%s AND resource=%s""", + (self.nodeDbId, + userhost, + resource)) + + row = cursor.fetchone() + if not row: + return None + else: + return Subscription(self.nodeIdentifier, subscriber, row[0]) + + def getSubscriptions(self, state=None): + return self.dbpool.runInteraction(self._getSubscriptions, state) + + def _getSubscriptions(self, cursor, state): + self._checkNodeExists(cursor) + + query = """SELECT node, jid, resource, state, + subscription_type, subscription_depth + FROM subscriptions + NATURAL JOIN nodes + NATURAL JOIN entities + WHERE node_id=%s""" + values = [self.nodeDbId] + + if state: + query += " AND state=%s" + values.append(state) + + cursor.execute(query, values) + rows = cursor.fetchall() + + subscriptions = [] + for row in rows: + subscriber = jid.JID(u'%s/%s' % (row.jid, row.resource)) + + options = {} + if row.subscription_type: + options['pubsub#subscription_type'] = row.subscription_type; + if row.subscription_depth: + options['pubsub#subscription_depth'] = row.subscription_depth; + + subscriptions.append(Subscription(row.node, subscriber, + row.state, options)) + + return subscriptions + + def addSubscription(self, subscriber, state, config): + return self.dbpool.runInteraction(self._addSubscription, subscriber, + state, config) + + def _addSubscription(self, cursor, subscriber, state, config): + self._checkNodeExists(cursor) + + userhost = subscriber.userhost() + resource = subscriber.resource or '' + + subscription_type = config.get('pubsub#subscription_type') + subscription_depth = config.get('pubsub#subscription_depth') + + try: + cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", + (userhost,)) + except cursor._pool.dbapi.IntegrityError: + cursor.connection.rollback() + + try: + cursor.execute("""INSERT INTO subscriptions + (node_id, entity_id, resource, state, + subscription_type, subscription_depth) + SELECT %s, entity_id, %s, %s, %s, %s FROM + (SELECT entity_id FROM entities + WHERE jid=%s) AS ent_id""", + (self.nodeDbId, + resource, + state, + subscription_type, + subscription_depth, + userhost)) + except cursor._pool.dbapi.IntegrityError: + raise error.SubscriptionExists() + + def removeSubscription(self, subscriber): + return self.dbpool.runInteraction(self._removeSubscription, + subscriber) + + def _removeSubscription(self, cursor, subscriber): + self._checkNodeExists(cursor) + + userhost = subscriber.userhost() + resource = subscriber.resource or '' + + cursor.execute("""DELETE FROM subscriptions WHERE + node_id=%s AND + entity_id=(SELECT entity_id FROM entities + WHERE jid=%s) AND + resource=%s""", + (self.nodeDbId, + userhost, + resource)) + if cursor.rowcount != 1: + raise error.NotSubscribed() + + return None + + def setSubscriptions(self, subscriptions): + return self.dbpool.runInteraction(self._setSubscriptions, subscriptions) + + def _setSubscriptions(self, cursor, subscriptions): + self._checkNodeExists(cursor) + + entities = self.getOrCreateEntities(cursor, [s.subscriber for s in subscriptions]) + entities_map = {jid.JID(e.jid): e for e in entities} + + # then we construct values for subscriptions update according to entity_id we just got + placeholders = ','.join(len(subscriptions) * ["%s"]) + values = [] + for subscription in subscriptions: + entity_id = entities_map[subscription.subscriber].entity_id + resource = subscription.subscriber.resource or u'' + values.append((self.nodeDbId, entity_id, resource, subscription.state, None, None)) + # we use upsert so new values are inserted and existing one updated. This feature is only available for PostgreSQL >= 9.5 + cursor.execute("INSERT INTO subscriptions(node_id, entity_id, resource, state, subscription_type, subscription_depth) VALUES " + placeholders + " ON CONFLICT (entity_id, resource, node_id) DO UPDATE SET state=EXCLUDED.state", [v for v in values]) + + def isSubscribed(self, entity): + return self.dbpool.runInteraction(self._isSubscribed, entity) + + def _isSubscribed(self, cursor, entity): + self._checkNodeExists(cursor) + + cursor.execute("""SELECT 1 as bool FROM entities + NATURAL JOIN subscriptions + NATURAL JOIN nodes + WHERE entities.jid=%s + AND node_id=%s AND state='subscribed'""", + (entity.userhost(), + self.nodeDbId)) + + return cursor.fetchone() is not None + + def getAffiliations(self): + return self.dbpool.runInteraction(self._getAffiliations) + + def _getAffiliations(self, cursor): + self._checkNodeExists(cursor) + + cursor.execute("""SELECT jid, affiliation FROM nodes + NATURAL JOIN affiliations + NATURAL JOIN entities + WHERE node_id=%s""", + (self.nodeDbId,)) + result = cursor.fetchall() + + return {jid.internJID(r[0]): r[1] for r in result} + + def getOrCreateEntities(self, cursor, entities_jids): + """Get entity_id from entities in entities table + + Entities will be inserted it they don't exist + @param entities_jid(list[jid.JID]): entities to get or create + @return list[record(entity_id,jid)]]: list of entity_id and jid (as plain string) + both existing and inserted entities are returned + """ + # cf. http://stackoverflow.com/a/35265559 + placeholders = ','.join(len(entities_jids) * ["(%s)"]) + query = ( + """ + WITH + jid_values (jid) AS ( + VALUES {placeholders} + ), + inserted (entity_id, jid) AS ( + INSERT INTO entities (jid) + SELECT jid + FROM jid_values + ON CONFLICT DO NOTHING + RETURNING entity_id, jid + ) + SELECT e.entity_id, e.jid + FROM entities e JOIN jid_values jv ON jv.jid = e.jid + UNION ALL + SELECT entity_id, jid + FROM inserted""".format(placeholders=placeholders)) + cursor.execute(query, [j.userhost() for j in entities_jids]) + return cursor.fetchall() + + def setAffiliations(self, affiliations): + return self.dbpool.runInteraction(self._setAffiliations, affiliations) + + def _setAffiliations(self, cursor, affiliations): + self._checkNodeExists(cursor) + + entities = self.getOrCreateEntities(cursor, affiliations) + + # then we construct values for affiliations update according to entity_id we just got + placeholders = ','.join(len(affiliations) * ["(%s,%s,%s)"]) + values = [] + map(values.extend, ((e.entity_id, affiliations[jid.JID(e.jid)], self.nodeDbId) for e in entities)) + + # we use upsert so new values are inserted and existing one updated. This feature is only available for PostgreSQL >= 9.5 + cursor.execute("INSERT INTO affiliations(entity_id,affiliation,node_id) VALUES " + placeholders + " ON CONFLICT (entity_id,node_id) DO UPDATE SET affiliation=EXCLUDED.affiliation", values) + + def deleteAffiliations(self, entities): + return self.dbpool.runInteraction(self._deleteAffiliations, entities) + + def _deleteAffiliations(self, cursor, entities): + """delete affiliations and subscriptions for this entity""" + self._checkNodeExists(cursor) + placeholders = ','.join(len(entities) * ["%s"]) + cursor.execute("DELETE FROM affiliations WHERE node_id=%s AND entity_id in (SELECT entity_id FROM entities WHERE jid IN (" + placeholders + ")) RETURNING entity_id", [self.nodeDbId] + [e.userhost() for e in entities]) + + rows = cursor.fetchall() + placeholders = ','.join(len(rows) * ["%s"]) + cursor.execute("DELETE FROM subscriptions WHERE node_id=%s AND entity_id in (" + placeholders + ")", [self.nodeDbId] + [r[0] for r in rows]) + + def getAuthorizedGroups(self): + return self.dbpool.runInteraction(self._getNodeGroups) + + def _getAuthorizedGroups(self, cursor): + cursor.execute("SELECT groupname FROM node_groups_authorized NATURAL JOIN nodes WHERE node=%s", + (self.nodeDbId,)) + rows = cursor.fetchall() + return [row[0] for row in rows] + + +class LeafNode(Node): + + implements(iidavoll.ILeafNode) + + nodeType = 'leaf' + + def getOrderBy(self, ext_data, direction='DESC'): + """Return ORDER BY clause corresponding to Order By key in ext_data + + @param ext_data (dict): extra data as used in getItems + @param direction (unicode): ORDER BY direction (ASC or DESC) + @return (unicode): ORDER BY clause to use + """ + keys = ext_data.get('order_by') + if not keys: + return u'ORDER BY updated ' + direction + cols_statmnt = [] + for key in keys: + if key == 'creation': + column = 'item_id' # could work with items.created too + elif key == 'modification': + column = 'updated' + else: + log.msg(u"WARNING: Unknown order by key: {key}".format(key=key)) + column = 'updated' + cols_statmnt.append(column + u' ' + direction) + + return u"ORDER BY " + u",".join([col for col in cols_statmnt]) + + @defer.inlineCallbacks + def storeItems(self, items_data, publisher): + # XXX: runInteraction doesn't seem to work when there are several "insert" + # or "update". + # Before the unpacking was done in _storeItems, but this was causing trouble + # in case of multiple items_data. So this has now be moved here. + # FIXME: investigate the issue with runInteraction + for item_data in items_data: + yield self.dbpool.runInteraction(self._storeItems, item_data, publisher) + + def _storeItems(self, cursor, item_data, publisher): + self._checkNodeExists(cursor) + self._storeItem(cursor, item_data, publisher) + + def _storeItem(self, cursor, item_data, publisher): + # first try to insert the item + # - if it fails (conflict), and the item is new and we have serial_ids options, + # current id will be recomputed using next item id query (note that is not perfect, as + # table is not locked and this can fail if two items are added at the same time + # but this can only happen with serial_ids and if future ids have been set by a client, + # this case should be rare enough to consider this situation acceptable) + # - if item insertion fail and the item is not new, we do an update + # - in other cases, exception is raised + item, access_model, item_config = item_data.item, item_data.access_model, item_data.config + data = item.toXml() + + insert_query = """INSERT INTO items (node_id, item, publisher, data, access_model) + SELECT %s, %s, %s, %s, %s FROM nodes + WHERE node_id=%s + RETURNING item_id""" + insert_data = [self.nodeDbId, + item["id"], + publisher.full(), + data, + access_model, + self.nodeDbId] + + try: + cursor.execute(insert_query, insert_data) + except cursor._pool.dbapi.IntegrityError as e: + if e.pgcode != "23505": + # we only handle unique_violation, every other exception must be raised + raise e + cursor.connection.rollback() + # the item already exist + if item_data.new: + # the item is new + if self._config[const.OPT_SERIAL_IDS]: + # this can happen with serial_ids, if a item has been stored + # with a future id (generated by XMPP client) + cursor.execute(NEXT_ITEM_ID_QUERY.format(node_id=self.nodeDbId)) + next_id = cursor.fetchone()[0] + # we update the sequence, so we can skip conflicting ids + cursor.execute(u"SELECT setval('{seq_name}', %s)".format( + seq_name = ITEMS_SEQ_NAME.format(node_id=self.nodeDbId)), [next_id]) + # and now we can retry the query with the new id + item['id'] = insert_data[1] = unicode(next_id) + # item saved in DB must also be updated with the new id + insert_data[3] = item.toXml() + cursor.execute(insert_query, insert_data) + else: + # but if we have not serial_ids, we have a real problem + raise e + else: + # this is an update + cursor.execute("""UPDATE items SET updated=now(), publisher=%s, data=%s + FROM nodes + WHERE nodes.node_id = items.node_id AND + nodes.node_id = %s and items.item=%s + RETURNING item_id""", + (publisher.full(), + data, + self.nodeDbId, + item["id"])) + if cursor.rowcount != 1: + raise exceptions.InternalError("item has not been updated correctly") + item_id = cursor.fetchone()[0]; + self._storeCategories(cursor, item_id, item_data.categories, update=True) + return + + item_id = cursor.fetchone()[0]; + self._storeCategories(cursor, item_id, item_data.categories) + + if access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: + if const.OPT_ROSTER_GROUPS_ALLOWED in item_config: + item_config.fields[const.OPT_ROSTER_GROUPS_ALLOWED].fieldType='list-multi' #XXX: needed to force list if there is only one value + allowed_groups = item_config[const.OPT_ROSTER_GROUPS_ALLOWED] + else: + allowed_groups = [] + for group in allowed_groups: + #TODO: check that group are actually in roster + cursor.execute("""INSERT INTO item_groups_authorized (item_id, groupname) + VALUES (%s,%s)""" , (item_id, group)) + # TODO: whitelist access model + + def _storeCategories(self, cursor, item_id, categories, update=False): + # TODO: handle canonical form + if update: + cursor.execute("""DELETE FROM item_categories + WHERE item_id=%s""", (item_id,)) + + # we use a set to avoid duplicates + for category in set(categories): + cursor.execute("""INSERT INTO item_categories (item_id, category) + VALUES (%s, %s)""", (item_id, category)) + + def removeItems(self, itemIdentifiers): + return self.dbpool.runInteraction(self._removeItems, itemIdentifiers) + + def _removeItems(self, cursor, itemIdentifiers): + self._checkNodeExists(cursor) + + deleted = [] + + for itemIdentifier in itemIdentifiers: + cursor.execute("""DELETE FROM items WHERE + node_id=%s AND + item=%s""", + (self.nodeDbId, + itemIdentifier)) + + if cursor.rowcount: + deleted.append(itemIdentifier) + + return deleted + + def getItems(self, authorized_groups, unrestricted, maxItems=None, ext_data=None): + """ Get all authorised items + + @param authorized_groups: we want to get items that these groups can access + @param unrestricted: if true, don't check permissions (i.e.: get all items) + @param maxItems: nb of items we want to get + @param ext_data: options for extra features like RSM and MAM + + @return: list of container.ItemData + if unrestricted is False, access_model and config will be None + """ + if ext_data is None: + ext_data = {} + return self.dbpool.runInteraction(self._getItems, authorized_groups, unrestricted, maxItems, ext_data, ids_only=False) + + def getItemsIds(self, authorized_groups, unrestricted, maxItems=None, ext_data=None): + """ Get all authorised items ids + + @param authorized_groups: we want to get items that these groups can access + @param unrestricted: if true, don't check permissions (i.e.: get all items) + @param maxItems: nb of items we want to get + @param ext_data: options for extra features like RSM and MAM + + @return list(unicode): list of ids + """ + if ext_data is None: + ext_data = {} + return self.dbpool.runInteraction(self._getItems, authorized_groups, unrestricted, maxItems, ext_data, ids_only=True) + + def _appendSourcesAndFilters(self, query, args, authorized_groups, unrestricted, ext_data): + """append sources and filters to sql query requesting items and return ORDER BY + + arguments query, args, authorized_groups, unrestricted and ext_data are the same as for + _getItems + """ + # SOURCES + query.append("FROM nodes INNER JOIN items USING (node_id)") + + if unrestricted: + query_filters = ["WHERE node_id=%s"] + args.append(self.nodeDbId) + else: + query.append("LEFT JOIN item_groups_authorized USING (item_id)") + args.append(self.nodeDbId) + if authorized_groups: + get_groups = " or (items.access_model='roster' and groupname in %s)" + args.append(authorized_groups) + else: + get_groups = "" + + query_filters = ["WHERE node_id=%s AND (items.access_model='open'" + get_groups + ")"] + + # FILTERS + if 'filters' in ext_data: # MAM filters + for filter_ in ext_data['filters']: + if filter_.var == 'start': + query_filters.append("AND created>=%s") + args.append(filter_.value) + elif filter_.var == 'end': + query_filters.append("AND created<=%s") + args.append(filter_.value) + elif filter_.var == 'with': + jid_s = filter_.value + if '/' in jid_s: + query_filters.append("AND publisher=%s") + args.append(filter_.value) + else: + query_filters.append("AND publisher LIKE %s") + args.append(u"{}%".format(filter_.value)) + elif filter_.var == const.MAM_FILTER_CATEGORY: + query.append("LEFT JOIN item_categories USING (item_id)") + query_filters.append("AND category=%s") + args.append(filter_.value) + else: + log.msg("WARNING: unknown filter: {}".format(filter_.encode('utf-8'))) + + query.extend(query_filters) + + return self.getOrderBy(ext_data) + + def _getItems(self, cursor, authorized_groups, unrestricted, maxItems, ext_data, ids_only): + self._checkNodeExists(cursor) + + if maxItems == 0: + return [] + + args = [] + + # SELECT + if ids_only: + query = ["SELECT item"] + else: + query = ["SELECT data::text,items.access_model,item_id,created,updated"] + + query_order = self._appendSourcesAndFilters(query, args, authorized_groups, unrestricted, ext_data) + + if 'rsm' in ext_data: + rsm = ext_data['rsm'] + maxItems = rsm.max + if rsm.index is not None: + # We need to know the item_id of corresponding to the index (offset) of the current query + # so we execute the query to look for the item_id + tmp_query = query[:] + tmp_args = args[:] + tmp_query[0] = "SELECT item_id" + tmp_query.append("{} LIMIT 1 OFFSET %s".format(query_order)) + tmp_args.append(rsm.index) + cursor.execute(' '.join(query), args) + # FIXME: bad index is not managed yet + item_id = cursor.fetchall()[0][0] + + # now that we have the id, we can use it + query.append("AND item_id<=%s") + args.append(item_id) + elif rsm.before is not None: + if rsm.before != '': + query.append("AND item_id>(SELECT item_id FROM items WHERE item=%s LIMIT 1)") + args.append(rsm.before) + if maxItems is not None: + # if we have maxItems (i.e. a limit), we need to reverse order + # in a first query to get the right items + query.insert(0,"SELECT * from (") + query.append(self.getOrderBy(ext_data, direction='ASC')) + query.append("LIMIT %s) as x") + args.append(maxItems) + elif rsm.after: + query.append("AND item_id<(SELECT item_id FROM items WHERE item=%s LIMIT 1)") + args.append(rsm.after) + + query.append(query_order) + + if maxItems is not None: + query.append("LIMIT %s") + args.append(maxItems) + + cursor.execute(' '.join(query), args) + + result = cursor.fetchall() + if unrestricted and not ids_only: + # with unrestricted query, we need to fill the access_list for a roster access items + ret = [] + for item_data in result: + item = generic.stripNamespace(parseXml(item_data.data)) + access_model = item_data.access_model + item_id = item_data.item_id + created = item_data.created + updated = item_data.updated + access_list = {} + if access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: + cursor.execute('SELECT groupname FROM item_groups_authorized WHERE item_id=%s', (item_id,)) + access_list[const.OPT_ROSTER_GROUPS_ALLOWED] = [r.groupname for r in cursor.fetchall()] + + ret.append(container.ItemData(item, access_model, access_list, created=created, updated=updated)) + # TODO: whitelist item access model + return ret + + if ids_only: + return [r.item for r in result] + else: + items_data = [container.ItemData(generic.stripNamespace(parseXml(r.data)), r.access_model, created=r.created, updated=r.updated) for r in result] + return items_data + + def getItemsById(self, authorized_groups, unrestricted, itemIdentifiers): + """Get items which are in the given list + + @param authorized_groups: we want to get items that these groups can access + @param unrestricted: if true, don't check permissions + @param itemIdentifiers: list of ids of the items we want to get + @return: list of container.ItemData + ItemData.config will contains access_list (managed as a dictionnary with same key as for item_config) + if unrestricted is False, access_model and config will be None + """ + return self.dbpool.runInteraction(self._getItemsById, authorized_groups, unrestricted, itemIdentifiers) + + def _getItemsById(self, cursor, authorized_groups, unrestricted, itemIdentifiers): + self._checkNodeExists(cursor) + ret = [] + if unrestricted: #we get everything without checking permissions + for itemIdentifier in itemIdentifiers: + cursor.execute("""SELECT data::text,items.access_model,item_id,created,updated FROM nodes + INNER JOIN items USING (node_id) + WHERE node_id=%s AND item=%s""", + (self.nodeDbId, + itemIdentifier)) + result = cursor.fetchone() + if not result: + raise error.ItemNotFound() + + item = generic.stripNamespace(parseXml(result[0])) + access_model = result[1] + item_id = result[2] + created= result[3] + updated= result[4] + access_list = {} + if access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: + cursor.execute('SELECT groupname FROM item_groups_authorized WHERE item_id=%s', (item_id,)) + access_list[const.OPT_ROSTER_GROUPS_ALLOWED] = [r[0] for r in cursor.fetchall()] + #TODO: WHITELIST access_model + + ret.append(container.ItemData(item, access_model, access_list, created=created, updated=updated)) + else: #we check permission before returning items + for itemIdentifier in itemIdentifiers: + args = [self.nodeDbId, itemIdentifier] + if authorized_groups: + args.append(authorized_groups) + cursor.execute("""SELECT data::text, created, updated FROM nodes + INNER JOIN items USING (node_id) + LEFT JOIN item_groups_authorized USING (item_id) + WHERE node_id=%s AND item=%s AND + (items.access_model='open' """ + + ("or (items.access_model='roster' and groupname in %s)" if authorized_groups else '') + ")", + args) + + result = cursor.fetchone() + if result: + ret.append(container.ItemData(generic.stripNamespace(parseXml(result[0])), created=result[1], updated=result[2])) + + return ret + + def getItemsCount(self, authorized_groups, unrestricted, ext_data=None): + """Count expected number of items in a getItems query + + @param authorized_groups: we want to get items that these groups can access + @param unrestricted: if true, don't check permissions (i.e.: get all items) + @param ext_data: options for extra features like RSM and MAM + """ + if ext_data is None: + ext_data = {} + return self.dbpool.runInteraction(self._getItemsCount, authorized_groups, unrestricted, ext_data) + + def _getItemsCount(self, cursor, authorized_groups, unrestricted, ext_data): + self._checkNodeExists(cursor) + args = [] + + # SELECT + query = ["SELECT count(1)"] + + self._appendSourcesAndFilters(query, args, authorized_groups, unrestricted, ext_data) + + cursor.execute(' '.join(query), args) + return cursor.fetchall()[0][0] + + def getItemsIndex(self, item_id, authorized_groups, unrestricted, ext_data=None): + """Get expected index of first item in the window of a getItems query + + @param item_id: id of the item + @param authorized_groups: we want to get items that these groups can access + @param unrestricted: if true, don't check permissions (i.e.: get all items) + @param ext_data: options for extra features like RSM and MAM + """ + if ext_data is None: + ext_data = {} + return self.dbpool.runInteraction(self._getItemsIndex, item_id, authorized_groups, unrestricted, ext_data) + + def _getItemsIndex(self, cursor, item_id, authorized_groups, unrestricted, ext_data): + self._checkNodeExists(cursor) + args = [] + + # SELECT + query = [] + + query_order = self._appendSourcesAndFilters(query, args, authorized_groups, unrestricted, ext_data) + + query_select = "SELECT row_number from (SELECT row_number() OVER ({}), item".format(query_order) + query.insert(0, query_select) + query.append(") as x WHERE item=%s") + args.append(item_id) + + cursor.execute(' '.join(query), args) + # XXX: row_number start at 1, but we want that index start at 0 + try: + return cursor.fetchall()[0][0] - 1 + except IndexError: + raise error.NodeNotFound() + + def getItemsPublishers(self, itemIdentifiers): + """Get the publishers for all given identifiers + + @return (dict[unicode, jid.JID]): map of itemIdentifiers to publisher + if item is not found, key is skipped in resulting dict + """ + return self.dbpool.runInteraction(self._getItemsPublishers, itemIdentifiers) + + def _getItemsPublishers(self, cursor, itemIdentifiers): + self._checkNodeExists(cursor) + ret = {} + for itemIdentifier in itemIdentifiers: + cursor.execute("""SELECT publisher FROM items + WHERE node_id=%s AND item=%s""", + (self.nodeDbId, itemIdentifier,)) + result = cursor.fetchone() + if result: + ret[itemIdentifier] = jid.JID(result[0]) + return ret + + def purge(self): + return self.dbpool.runInteraction(self._purge) + + def _purge(self, cursor): + self._checkNodeExists(cursor) + + cursor.execute("""DELETE FROM items WHERE + node_id=%s""", + (self.nodeDbId,)) + + +class CollectionNode(Node): + + nodeType = 'collection' + + + +class GatewayStorage(object): + """ + Memory based storage facility for the XMPP-HTTP gateway. + """ + + def __init__(self, dbpool): + self.dbpool = dbpool + + def _countCallbacks(self, cursor, service, nodeIdentifier): + """ + Count number of callbacks registered for a node. + """ + cursor.execute("""SELECT count(*) FROM callbacks + WHERE service=%s and node=%s""", + (service.full(), + nodeIdentifier)) + results = cursor.fetchall() + return results[0][0] + + def addCallback(self, service, nodeIdentifier, callback): + def interaction(cursor): + cursor.execute("""SELECT 1 as bool FROM callbacks + WHERE service=%s and node=%s and uri=%s""", + (service.full(), + nodeIdentifier, + callback)) + if cursor.fetchall(): + return + + cursor.execute("""INSERT INTO callbacks + (service, node, uri) VALUES + (%s, %s, %s)""", + (service.full(), + nodeIdentifier, + callback)) + + return self.dbpool.runInteraction(interaction) + + def removeCallback(self, service, nodeIdentifier, callback): + def interaction(cursor): + cursor.execute("""DELETE FROM callbacks + WHERE service=%s and node=%s and uri=%s""", + (service.full(), + nodeIdentifier, + callback)) + + if cursor.rowcount != 1: + raise error.NotSubscribed() + + last = not self._countCallbacks(cursor, service, nodeIdentifier) + return last + + return self.dbpool.runInteraction(interaction) + + def getCallbacks(self, service, nodeIdentifier): + def interaction(cursor): + cursor.execute("""SELECT uri FROM callbacks + WHERE service=%s and node=%s""", + (service.full(), + nodeIdentifier)) + results = cursor.fetchall() + + if not results: + raise error.NoCallbacks() + + return [result[0] for result in results] + + return self.dbpool.runInteraction(interaction) + + def hasCallbacks(self, service, nodeIdentifier): + def interaction(cursor): + return bool(self._countCallbacks(cursor, service, nodeIdentifier)) + + return self.dbpool.runInteraction(interaction) diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/privilege.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/privilege.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,312 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- +# +# Copyright (c) 2015 Jérôme Poisson + + +# 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 . + +# --- + +# This module implements XEP-0356 (Privileged Entity) to manage rosters, messages and presences + +from wokkel import xmppim +from wokkel.compat import IQ +from wokkel import pubsub +from wokkel import disco +from wokkel.iwokkel import IPubSubService +from twisted.python import log +from twisted.python import failure +from twisted.internet import defer +from twisted.words.xish import domish +from twisted.words.protocols.jabber import jid +import time + +FORWARDED_NS = 'urn:xmpp:forward:0' +PRIV_ENT_NS = 'urn:xmpp:privilege:1' +PRIV_ENT_ADV_XPATH = '/message/privilege[@xmlns="{}"]'.format(PRIV_ENT_NS) +ROSTER_NS = 'jabber:iq:roster' +PERM_ROSTER = 'roster' +PERM_MESSAGE = 'message' +PERM_PRESENCE = 'presence' +ALLOWED_ROSTER = ('none', 'get', 'set', 'both') +ALLOWED_MESSAGE = ('none', 'outgoing') +ALLOWED_PRESENCE = ('none', 'managed_entity', 'roster') +TO_CHECK = {PERM_ROSTER:ALLOWED_ROSTER, PERM_MESSAGE:ALLOWED_MESSAGE, PERM_PRESENCE:ALLOWED_PRESENCE} + + +class InvalidStanza(Exception): + pass + +class NotAllowedError(Exception): + pass + +class PrivilegesHandler(disco.DiscoClientProtocol): + #FIXME: need to manage updates, and database sync + #TODO: cache + + def __init__(self, service_jid): + super(PrivilegesHandler, self).__init__() + self._permissions = {PERM_ROSTER: 'none', + PERM_MESSAGE: 'none', + PERM_PRESENCE: 'none'} + self._pubsub_service = None + self._backend = None + # FIXME: we use a hack supposing that our privilege come from hostname + # and we are a component named [name].hostname + # but we need to manage properly server + # TODO: do proper server handling + self.server_jid = jid.JID(service_jid.host.split('.', 1)[1]) + self.caps_map = {} # key: bare jid, value: dict of resources with caps hash + self.hash_map = {} # key: (hash,version), value: dict with DiscoInfo instance (infos) and nodes to notify (notify) + self.roster_cache = {} # key: jid, value: dict with "timestamp" and "roster" + self.presence_map = {} # inverted roster: key: jid, value: set of entities who has this jid in roster (with presence of "from" or "both") + self.server = None + + @property + def permissions(self): + return self._permissions + + def connectionInitialized(self): + for handler in self.parent.handlers: + if IPubSubService.providedBy(handler): + self._pubsub_service = handler + break + self._backend = self.parent.parent.getServiceNamed('backend') + self.xmlstream.addObserver(PRIV_ENT_ADV_XPATH, self.onAdvertise) + self.xmlstream.addObserver('/presence', self.onPresence) + + def onAdvertise(self, message): + """Managage the advertising privileges + + self._permissions will be updated according to advertised privileged + """ + privilege_elt = message.elements(PRIV_ENT_NS, 'privilege').next() + for perm_elt in privilege_elt.elements(PRIV_ENT_NS): + try: + if perm_elt.name != 'perm': + raise InvalidStanza(u'unexpected element {}'.format(perm_elt.name)) + perm_access = perm_elt['access'] + perm_type = perm_elt['type'] + try: + if perm_type not in TO_CHECK[perm_access]: + raise InvalidStanza(u'bad type [{}] for permission {}'.format(perm_type, perm_access)) + except KeyError: + raise InvalidStanza(u'bad permission [{}]'.format(perm_access)) + except InvalidStanza as e: + log.msg("Invalid stanza received ({}), setting permission to none".format(e)) + for perm in self._permissions: + self._permissions[perm] = 'none' + break + + self._permissions[perm_access] = perm_type or 'none' + + log.msg('Privileges updated: roster={roster}, message={message}, presence={presence}'.format(**self._permissions)) + + ## roster ## + + def getRoster(self, to_jid): + """ + Retrieve contact list. + + @return: Roster as a mapping from L{JID} to L{RosterItem}. + @rtype: L{twisted.internet.defer.Deferred} + """ + # TODO: cache results + if self._permissions[PERM_ROSTER] not in ('get', 'both'): + log.msg("WARNING: permission not allowed to get roster") + raise failure.Failure(NotAllowedError('roster get is not allowed')) + + def processRoster(result): + roster = {} + for element in result.query.elements(ROSTER_NS, 'item'): + item = xmppim.RosterItem.fromElement(element) + roster[item.entity] = item + + return roster + + iq = IQ(self.xmlstream, 'get') + iq.addElement((ROSTER_NS, 'query')) + iq["to"] = to_jid.userhost() + d = iq.send() + d.addCallback(processRoster) + return d + + def _isSubscribedFrom(self, roster, entity, roster_owner_jid): + try: + return roster[entity.userhostJID()].subscriptionFrom + except KeyError: + return False + + def isSubscribedFrom(self, entity, roster_owner_jid): + """Check if entity has presence subscription from roster_owner_jid + + @param entity(jid.JID): entity to check subscription to + @param roster_owner_jid(jid.JID): owner of the roster to check + @return D(bool): True if entity has a subscription from roster_owner_jid + """ + d = self.getRoster(roster_owner_jid) + d.addCallback(self._isSubscribedFrom, entity, roster_owner_jid) + return d + + ## message ## + + def sendMessage(self, priv_message, to_jid=None): + """Send privileged message (in the name of the server) + + @param priv_message(domish.Element): privileged message + @param to_jid(jid.JID, None): main message destinee + None to use our own server + """ + if self._permissions[PERM_MESSAGE] not in ('outgoing',): + log.msg("WARNING: permission not allowed to send privileged messages") + raise failure.Failure(NotAllowedError('privileged messages are not allowed')) + + main_message = domish.Element((None, "message")) + if to_jid is None: + to_jid = self.server_jid + main_message['to'] = to_jid.full() + privilege_elt = main_message.addElement((PRIV_ENT_NS, 'privilege')) + forwarded_elt = privilege_elt.addElement((FORWARDED_NS, 'forwarded')) + priv_message['xmlns'] = 'jabber:client' + forwarded_elt.addChild(priv_message) + self.send(main_message) + + def notifyPublish(self, pep_jid, nodeIdentifier, notifications): + """Do notifications using privileges""" + for subscriber, subscriptions, items in notifications: + message = self._pubsub_service._createNotification('items', pep_jid, + nodeIdentifier, subscriber, + subscriptions) + for item in items: + item.uri = pubsub.NS_PUBSUB_EVENT + message.event.items.addChild(item) + self.sendMessage(message) + + + def notifyRetract(self, pep_jid, nodeIdentifier, notifications): + for subscriber, subscriptions, items in notifications: + message = self._pubsub_service._createNotification('items', pep_jid, + nodeIdentifier, subscriber, + subscriptions) + for item in items: + retract = domish.Element((None, "retract")) + retract['id'] = item['id'] + message.event.items.addChild(retract) + self.sendMessage(message) + + + # def notifyDelete(self, service, nodeIdentifier, subscribers, + # redirectURI=None): + # # TODO + # for subscriber in subscribers: + # message = self._createNotification('delete', service, + # nodeIdentifier, + # subscriber) + # if redirectURI: + # redirect = message.event.delete.addElement('redirect') + # redirect['uri'] = redirectURI + # self.send(message) + + + ## presence ## + + @defer.inlineCallbacks + def onPresence(self, presence_elt): + if self.server is None: + # FIXME: we use a hack supposing that our delegation come from hostname + # and we are a component named [name].hostname + # but we need to manage properly allowed servers + # TODO: do proper origin security check + _, self.server = presence_elt['to'].split('.', 1) + from_jid = jid.JID(presence_elt['from']) + from_jid_bare = from_jid.userhostJID() + if from_jid.host == self.server and from_jid_bare not in self.roster_cache: + roster = yield self.getRoster(from_jid_bare) + timestamp = time.time() + self.roster_cache[from_jid_bare] = {'timestamp': timestamp, + 'roster': roster, + } + for roster_jid, roster_item in roster.iteritems(): + if roster_item.subscriptionFrom: + self.presence_map.setdefault(roster_jid, set()).add(from_jid_bare) + + presence_type = presence_elt.getAttribute('type') + if presence_type != "unavailable": + # new resource available, we check entity capabilities + try: + c_elt = next(presence_elt.elements('http://jabber.org/protocol/caps', 'c')) + hash_ = c_elt['hash'] + ver = c_elt['ver'] + except (StopIteration, KeyError): + # no capabilities, we don't go further + return + + # FIXME: hash is not checked (cf. XEP-0115) + disco_tuple = (hash_, ver) + + if disco_tuple not in self.hash_map: + # first time we se this hash, what is behind it? + infos = yield self.requestInfo(from_jid) + self.hash_map[disco_tuple] = { + 'notify': {f[:-7] for f in infos.features if f.endswith('+notify')}, + 'infos': infos + } + + # jid_caps must be filled only after hash_map is set, to be sure that + # the hash data is available in getAutoSubscribers + jid_caps = self.caps_map.setdefault(from_jid_bare, {}) + if from_jid.resource not in jid_caps: + jid_caps[from_jid.resource] = disco_tuple + + # nodes are the nodes subscribed with +notify + nodes = tuple(self.hash_map[disco_tuple]['notify']) + if not nodes: + return + # publishers are entities which have granted presence access to our user + user itself + publishers = tuple(self.presence_map.get(from_jid_bare, ())) + (from_jid_bare,) + + # FIXME: add "presence" access_model (for node) for getLastItems + last_items = yield self._backend.storage.getLastItems(publishers, nodes, ('open',), ('open',), True) + # we send message with last item, as required by https://xmpp.org/extensions/xep-0163.html#notify-last + for pep_jid, node, item, item_access_model in last_items: + self.notifyPublish(pep_jid, node, [(from_jid, None, [item])]) + + ## misc ## + + @defer.inlineCallbacks + def getAutoSubscribers(self, recipient, nodeIdentifier, explicit_subscribers): + """get automatic subscribers, i.e. subscribers with presence subscription and +notify for this node + + @param recipient(jid.JID): jid of the PEP owner of this node + @param nodeIdentifier(unicode): node + @param explicit_subscribers(set(jid.JID}: jids of people which have an explicit subscription + @return (list[jid.JID]): full jid of automatically subscribed entities + """ + auto_subscribers = [] + roster = yield self.getRoster(recipient) + for roster_jid, roster_item in roster.iteritems(): + if roster_jid in explicit_subscribers: + continue + if roster_item.subscriptionFrom: + try: + online_resources = self.caps_map[roster_jid] + except KeyError: + continue + for res, disco_tuple in online_resources.iteritems(): + notify = self.hash_map[disco_tuple]['notify'] + if nodeIdentifier in notify: + full_jid = jid.JID(tuple=(roster_jid.user, roster_jid.host, res)) + auto_subscribers.append(full_jid) + defer.returnValue(auto_subscribers) diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/pubsub_admin.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/pubsub_admin.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,135 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2019 Jérôme Poisson +# +# 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 . + +""" +Pubsub Admin experimental protocol implementation + +""" + +from zope.interface import implements +from twisted.python import log +from twisted.internet import defer +from twisted.words.protocols.jabber import jid, error as jabber_error, xmlstream +from sat_pubsub import error +from wokkel.subprotocols import XMPPHandler +from wokkel import disco, iwokkel, pubsub + +NS_PUBSUB_ADMIN = u"https://salut-a-toi.org/spec/pubsub_admin:0" +ADMIN_REQUEST = '/iq[@type="set"]/admin[@xmlns="{}"]'.format(NS_PUBSUB_ADMIN) + + +class PubsubAdminHandler(XMPPHandler): + implements(iwokkel.IDisco) + + def __init__(self, backend): + super(PubsubAdminHandler, self).__init__() + self.backend = backend + + def connectionInitialized(self): + self.xmlstream.addObserver(ADMIN_REQUEST, self.onAdminRequest) + + def sendError(self, iq_elt, condition=u'bad-request'): + stanza_error = jabber_error.StanzaError(condition) + iq_error = stanza_error.toResponse(iq_elt) + self.parent.xmlstream.send(iq_error) + + @defer.inlineCallbacks + def onAdminRequest(self, iq_elt): + """Pubsub Admin request received""" + iq_elt.handled = True + try: + pep = bool(iq_elt.delegated) + except AttributeError: + pep = False + + # is the sender really an admin? + admins = self.backend.config[u'admins_jids_list'] + from_jid = jid.JID(iq_elt[u'from']) + if from_jid.userhostJID() not in admins: + log.msg("WARNING: admin request done by non admin entity {from_jid}" + .format(from_jid=from_jid.full())) + self.sendError(iq_elt, u'forbidden') + return + + # alright, we can proceed + recipient = jid.JID(iq_elt[u'to']) + admin_elt = iq_elt.admin + try: + pubsub_elt = next(admin_elt.elements(pubsub.NS_PUBSUB, u'pubsub')) + publish_elt = next(pubsub_elt.elements(pubsub.NS_PUBSUB, u'publish')) + except StopIteration: + self.sendError(iq_elt) + return + try: + node = publish_elt[u'node'] + except KeyError: + self.sendError(iq_elt) + return + + # we prepare the result IQ request, we will fill it with item ids + iq_result_elt = xmlstream.toResponse(iq_elt, u'result') + result_admin_elt = iq_result_elt.addElement((NS_PUBSUB_ADMIN, u'admin')) + result_pubsub_elt = result_admin_elt.addElement((pubsub.NS_PUBSUB, u'pubsub')) + result_publish_elt = result_pubsub_elt.addElement(u'publish') + result_publish_elt[u'node'] = node + + # now we can send the items + for item in publish_elt.elements(pubsub.NS_PUBSUB, u'item'): + try: + requestor = jid.JID(item.attributes.pop(u'publisher')) + except Exception as e: + log.msg(u"WARNING: invalid jid in publisher ({requestor}): {msg}" + .format(requestor=requestor, msg=e)) + self.sendError(iq_elt) + return + except KeyError: + requestor = from_jid + + # we don't use a DeferredList because we want to be sure that + # each request is done in order + try: + payload = yield self.backend.publish( + nodeIdentifier=node, + items=[item], + requestor=requestor, + pep=pep, + recipient=recipient) + except (error.Forbidden, error.ItemForbidden): + __import__('pudb').set_trace() + self.sendError(iq_elt, u"forbidden") + return + except Exception as e: + self.sendError(iq_elt, u"internal-server-error") + log.msg(u"INTERNAL ERROR: {msg}".format(msg=e)) + return + + result_item_elt = result_publish_elt.addElement(u'item') + # either the id was given and it is available in item + # either it's a new item, and we can retrieve it from return payload + try: + result_item_elt[u'id'] = item[u'id'] + except KeyError: + result_item_elt = payload.publish.item[u'id'] + + self.xmlstream.send(iq_result_elt) + + def getDiscoInfo(self, requestor, service, nodeIdentifier=''): + return [disco.DiscoFeature(NS_PUBSUB_ADMIN)] + + def getDiscoItems(self, requestor, service, nodeIdentifier=''): + return [] diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/schema.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/schema.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,104 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- +# +# Copyright (c) 2015 Jérôme Poisson + + +# 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 . + +# --- + +# This module implements node schema + +from twisted.words.protocols.jabber import jid +from twisted.words.xish import domish +from wokkel import disco, iwokkel +from wokkel.iwokkel import IPubSubService +from wokkel.subprotocols import XMPPHandler, IQHandlerMixin +from wokkel import data_form, pubsub +from zope.interface import implements +from sat_pubsub import const + +QUERY_SCHEMA = "/pubsub[@xmlns='" + const.NS_SCHEMA + "']" + + +class SchemaHandler(XMPPHandler, IQHandlerMixin): + implements(iwokkel.IDisco) + iqHandlers = {"/iq[@type='get']" + QUERY_SCHEMA: 'onSchemaGet', + "/iq[@type='set']" + QUERY_SCHEMA: 'onSchemaSet'} + + def __init__(self): + super(SchemaHandler, self).__init__() + self.pubsub_service = None + + def connectionInitialized(self): + for handler in self.parent.handlers: + if IPubSubService.providedBy(handler): + self.pubsub_service = handler + break + self.backend = self.parent.parent.getServiceNamed('backend') + self.xmlstream.addObserver("/iq[@type='get' or @type='set']" + QUERY_SCHEMA, self.handleRequest) + + def _getNodeSchemaCb(self, x_elt, nodeIdentifier): + schema_elt = domish.Element((const.NS_SCHEMA, 'schema')) + schema_elt['node'] = nodeIdentifier + if x_elt is not None: + assert x_elt.uri == u'jabber:x:data' + schema_elt.addChild(x_elt) + return schema_elt + + def onSchemaGet(self, iq_elt): + try: + schema_elt = next(iq_elt.pubsub.elements(const.NS_SCHEMA, 'schema')) + nodeIdentifier = schema_elt['node'] + except StopIteration: + raise pubsub.BadRequest(text='missing schema element') + except KeyError: + raise pubsub.BadRequest(text='missing node') + pep = iq_elt.delegated + recipient = jid.JID(iq_elt['to']) + d = self.backend.getNodeSchema(nodeIdentifier, + pep, + recipient) + d.addCallback(self._getNodeSchemaCb, nodeIdentifier) + return d.addErrback(self.pubsub_service.resource._mapErrors) + + def onSchemaSet(self, iq_elt): + try: + schema_elt = next(iq_elt.pubsub.elements(const.NS_SCHEMA, 'schema')) + nodeIdentifier = schema_elt['node'] + except StopIteration: + raise pubsub.BadRequest(text='missing schema element') + except KeyError: + raise pubsub.BadRequest(text='missing node') + requestor = jid.JID(iq_elt['from']) + pep = iq_elt.delegated + recipient = jid.JID(iq_elt['to']) + try: + x_elt = next(schema_elt.elements(data_form.NS_X_DATA, u'x')) + except StopIteration: + # no schema form has been found + x_elt = None + d = self.backend.setNodeSchema(nodeIdentifier, + x_elt, + requestor, + pep, + recipient) + return d.addErrback(self.pubsub_service.resource._mapErrors) + + def getDiscoInfo(self, requestor, service, nodeIdentifier=''): + return [disco.DiscoFeature(const.NS_SCHEMA)] + + def getDiscoItems(self, requestor, service, nodeIdentifier=''): + return [] diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/test/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/test/__init__.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,55 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2019 Jérôme Poisson + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +""" +Tests for L{idavoll}. +""" diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/test/test_backend.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/test/test_backend.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,692 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2019 Jérôme Poisson + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +""" +Tests for L{idavoll.backend}. +""" + +from zope.interface import implements +from zope.interface.verify import verifyObject + +from twisted.internet import defer +from twisted.trial import unittest +from twisted.words.protocols.jabber import jid +from twisted.words.protocols.jabber.error import StanzaError + +from wokkel import iwokkel, pubsub + +from sat_pubsub import backend, error, iidavoll, const + +OWNER = jid.JID('owner@example.com') +OWNER_FULL = jid.JID('owner@example.com/home') +SERVICE = jid.JID('test.example.org') +NS_PUBSUB = 'http://jabber.org/protocol/pubsub' + +class BackendTest(unittest.TestCase): + + def test_interfaceIBackend(self): + self.assertTrue(verifyObject(iidavoll.IBackendService, + backend.BackendService(None))) + + + def test_deleteNode(self): + class TestNode: + nodeIdentifier = 'to-be-deleted' + def getAffiliation(self, entity): + if entity.userhostJID() == OWNER: + return defer.succeed('owner') + + class TestStorage: + def __init__(self): + self.deleteCalled = [] + + def getNode(self, nodeIdentifier): + return defer.succeed(TestNode()) + + def deleteNode(self, nodeIdentifier): + if nodeIdentifier in ['to-be-deleted']: + self.deleteCalled.append(nodeIdentifier) + return defer.succeed(None) + else: + return defer.fail(error.NodeNotFound()) + + def preDelete(data): + self.assertFalse(self.storage.deleteCalled) + preDeleteCalled.append(data) + return defer.succeed(None) + + def cb(result): + self.assertEquals(1, len(preDeleteCalled)) + data = preDeleteCalled[-1] + self.assertEquals('to-be-deleted', data['node'].nodeIdentifier) + self.assertTrue(self.storage.deleteCalled) + + self.storage = TestStorage() + self.backend = backend.BackendService(self.storage) + + preDeleteCalled = [] + + self.backend.registerPreDelete(preDelete) + d = self.backend.deleteNode('to-be-deleted', OWNER_FULL) + d.addCallback(cb) + return d + + + def test_deleteNodeRedirect(self): + uri = 'xmpp:%s?;node=test2' % (SERVICE.full(),) + + class TestNode: + nodeIdentifier = 'to-be-deleted' + def getAffiliation(self, entity): + if entity.userhostJID() == OWNER: + return defer.succeed('owner') + + class TestStorage: + def __init__(self): + self.deleteCalled = [] + + def getNode(self, nodeIdentifier): + return defer.succeed(TestNode()) + + def deleteNode(self, nodeIdentifier): + if nodeIdentifier in ['to-be-deleted']: + self.deleteCalled.append(nodeIdentifier) + return defer.succeed(None) + else: + return defer.fail(error.NodeNotFound()) + + def preDelete(data): + self.assertFalse(self.storage.deleteCalled) + preDeleteCalled.append(data) + return defer.succeed(None) + + def cb(result): + self.assertEquals(1, len(preDeleteCalled)) + data = preDeleteCalled[-1] + self.assertEquals('to-be-deleted', data['node'].nodeIdentifier) + self.assertEquals(uri, data['redirectURI']) + self.assertTrue(self.storage.deleteCalled) + + self.storage = TestStorage() + self.backend = backend.BackendService(self.storage) + + preDeleteCalled = [] + + self.backend.registerPreDelete(preDelete) + d = self.backend.deleteNode('to-be-deleted', OWNER, redirectURI=uri) + d.addCallback(cb) + return d + + + def test_createNodeNoID(self): + """ + Test creation of a node without a given node identifier. + """ + class TestStorage: + def getDefaultConfiguration(self, nodeType): + return {} + + def createNode(self, nodeIdentifier, requestor, config): + self.nodeIdentifier = nodeIdentifier + return defer.succeed(None) + + self.storage = TestStorage() + self.backend = backend.BackendService(self.storage) + self.storage.backend = self.backend + + def checkID(nodeIdentifier): + self.assertNotIdentical(None, nodeIdentifier) + self.assertIdentical(self.storage.nodeIdentifier, nodeIdentifier) + + d = self.backend.createNode(None, OWNER_FULL) + d.addCallback(checkID) + return d + + class NodeStore: + """ + I just store nodes to pose as an L{IStorage} implementation. + """ + def __init__(self, nodes): + self.nodes = nodes + + def getNode(self, nodeIdentifier): + try: + return defer.succeed(self.nodes[nodeIdentifier]) + except KeyError: + return defer.fail(error.NodeNotFound()) + + + def test_getNotifications(self): + """ + Ensure subscribers show up in the notification list. + """ + item = pubsub.Item() + sub = pubsub.Subscription('test', OWNER, 'subscribed') + + class TestNode: + def getSubscriptions(self, state=None): + return [sub] + + def cb(result): + self.assertEquals(1, len(result)) + subscriber, subscriptions, items = result[-1] + + self.assertEquals(OWNER, subscriber) + self.assertEquals({sub}, subscriptions) + self.assertEquals([item], items) + + self.storage = self.NodeStore({'test': TestNode()}) + self.backend = backend.BackendService(self.storage) + d = self.backend.getNotifications('test', [item]) + d.addCallback(cb) + return d + + def test_getNotificationsRoot(self): + """ + Ensure subscribers to the root node show up in the notification list + for leaf nodes. + + This assumes a flat node relationship model with exactly one collection + node: the root node. Each leaf node is automatically a child node + of the root node. + """ + item = pubsub.Item() + subRoot = pubsub.Subscription('', OWNER, 'subscribed') + + class TestNode: + def getSubscriptions(self, state=None): + return [] + + class TestRootNode: + def getSubscriptions(self, state=None): + return [subRoot] + + def cb(result): + self.assertEquals(1, len(result)) + subscriber, subscriptions, items = result[-1] + self.assertEquals(OWNER, subscriber) + self.assertEquals({subRoot}, subscriptions) + self.assertEquals([item], items) + + self.storage = self.NodeStore({'test': TestNode(), + '': TestRootNode()}) + self.backend = backend.BackendService(self.storage) + d = self.backend.getNotifications('test', [item]) + d.addCallback(cb) + return d + + + def test_getNotificationsMultipleNodes(self): + """ + Ensure that entities that subscribe to a leaf node as well as the + root node get exactly one notification. + """ + item = pubsub.Item() + sub = pubsub.Subscription('test', OWNER, 'subscribed') + subRoot = pubsub.Subscription('', OWNER, 'subscribed') + + class TestNode: + def getSubscriptions(self, state=None): + return [sub] + + class TestRootNode: + def getSubscriptions(self, state=None): + return [subRoot] + + def cb(result): + self.assertEquals(1, len(result)) + subscriber, subscriptions, items = result[-1] + + self.assertEquals(OWNER, subscriber) + self.assertEquals({sub, subRoot}, subscriptions) + self.assertEquals([item], items) + + self.storage = self.NodeStore({'test': TestNode(), + '': TestRootNode()}) + self.backend = backend.BackendService(self.storage) + d = self.backend.getNotifications('test', [item]) + d.addCallback(cb) + return d + + + def test_getDefaultConfiguration(self): + """ + L{backend.BackendService.getDefaultConfiguration} should return + a deferred that fires a dictionary with configuration values. + """ + + class TestStorage: + def getDefaultConfiguration(self, nodeType): + return { + "pubsub#persist_items": True, + "pubsub#deliver_payloads": True} + + def cb(options): + self.assertIn("pubsub#persist_items", options) + self.assertEqual(True, options["pubsub#persist_items"]) + + self.backend = backend.BackendService(TestStorage()) + d = self.backend.getDefaultConfiguration('leaf') + d.addCallback(cb) + return d + + + def test_getNodeConfiguration(self): + class testNode: + nodeIdentifier = 'node' + def getConfiguration(self): + return {'pubsub#deliver_payloads': True, + 'pubsub#persist_items': False} + + class testStorage: + def getNode(self, nodeIdentifier): + return defer.succeed(testNode()) + + def cb(options): + self.assertIn("pubsub#deliver_payloads", options) + self.assertEqual(True, options["pubsub#deliver_payloads"]) + self.assertIn("pubsub#persist_items", options) + self.assertEqual(False, options["pubsub#persist_items"]) + + self.storage = testStorage() + self.backend = backend.BackendService(self.storage) + self.storage.backend = self.backend + + d = self.backend.getNodeConfiguration('node') + d.addCallback(cb) + return d + + + def test_setNodeConfiguration(self): + class testNode: + nodeIdentifier = 'node' + def getAffiliation(self, entity): + if entity.userhostJID() == OWNER: + return defer.succeed('owner') + def setConfiguration(self, options): + self.options = options + + class testStorage: + def __init__(self): + self.nodes = {'node': testNode()} + def getNode(self, nodeIdentifier): + return defer.succeed(self.nodes[nodeIdentifier]) + + def checkOptions(node): + options = node.options + self.assertIn("pubsub#deliver_payloads", options) + self.assertEqual(True, options["pubsub#deliver_payloads"]) + self.assertIn("pubsub#persist_items", options) + self.assertEqual(False, options["pubsub#persist_items"]) + + def cb(result): + d = self.storage.getNode('node') + d.addCallback(checkOptions) + return d + + self.storage = testStorage() + self.backend = backend.BackendService(self.storage) + self.storage.backend = self.backend + + options = {'pubsub#deliver_payloads': True, + 'pubsub#persist_items': False} + + d = self.backend.setNodeConfiguration('node', options, OWNER_FULL) + d.addCallback(cb) + return d + + + def test_publishNoID(self): + """ + Test publish request with an item without a node identifier. + """ + class TestNode: + nodeType = 'leaf' + nodeIdentifier = 'node' + def getAffiliation(self, entity): + if entity.userhostJID() == OWNER: + return defer.succeed('owner') + def getConfiguration(self): + return {'pubsub#deliver_payloads': True, + 'pubsub#persist_items': False, + const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_OPEN} + + class TestStorage: + def getNode(self, nodeIdentifier): + return defer.succeed(TestNode()) + + def checkID(notification): + self.assertNotIdentical(None, notification['items'][0][2]['id']) + + self.storage = TestStorage() + self.backend = backend.BackendService(self.storage) + self.storage.backend = self.backend + + self.backend.registerNotifier(checkID) + + items = [pubsub.Item()] + d = self.backend.publish('node', items, OWNER_FULL) + return d + + + def test_notifyOnSubscription(self): + """ + Test notification of last published item on subscription. + """ + ITEM = "" % NS_PUBSUB + + class TestNode: + implements(iidavoll.ILeafNode) + nodeIdentifier = 'node' + nodeType = 'leaf' + def getAffiliation(self, entity): + if entity is OWNER: + return defer.succeed('owner') + def getConfiguration(self): + return {'pubsub#deliver_payloads': True, + 'pubsub#persist_items': False, + 'pubsub#send_last_published_item': 'on_sub', + const.OPT_ACCESS_MODEL: const.VAL_AMODEL_OPEN} + def getItems(self, authorized_groups, unrestricted, maxItems): + return defer.succeed([(ITEM, const.VAL_AMODEL_OPEN, None)]) + def addSubscription(self, subscriber, state, options): + self.subscription = pubsub.Subscription('node', subscriber, + state, options) + return defer.succeed(None) + def getSubscription(self, subscriber): + return defer.succeed(self.subscription) + def getNodeOwner(self): + return defer.succeed(OWNER) + + class TestStorage: + def getNode(self, nodeIdentifier): + return defer.succeed(TestNode()) + + def cb(data): + self.assertEquals('node', data['node'].nodeIdentifier) + self.assertEquals([ITEM], data['items']) + self.assertEquals(OWNER, data['subscription'].subscriber) + + self.storage = TestStorage() + self.backend = backend.BackendService(self.storage) + self.storage.backend = self.backend + + class Roster(object): + def getRoster(self, owner): + return {} + self.backend.roster = Roster() + + d1 = defer.Deferred() + d1.addCallback(cb) + self.backend.registerNotifier(d1.callback) + d2 = self.backend.subscribe('node', OWNER, OWNER_FULL) + return defer.gatherResults([d1, d2]) + + test_notifyOnSubscription.timeout = 2 + + + +class BaseTestBackend(object): + """ + Base class for backend stubs. + """ + + def supportsPublisherAffiliation(self): + return True + + + def supportsOutcastAffiliation(self): + return True + + + def supportsPersistentItems(self): + return True + + + def supportsInstantNodes(self): + return True + + def supportsItemAccess(self): + return True + + def supportsAutoCreate(self): + return True + + def supportsCreatorCheck(self): + return True + + def supportsGroupBlog(self): + return True + + def registerNotifier(self, observerfn, *args, **kwargs): + return + + + def registerPreDelete(self, preDeleteFn): + return + + + +class PubSubResourceFromBackendTest(unittest.TestCase): + + def test_interface(self): + resource = backend.PubSubResourceFromBackend(BaseTestBackend()) + self.assertTrue(verifyObject(iwokkel.IPubSubResource, resource)) + + + def test_preDelete(self): + """ + Test pre-delete sending out notifications to subscribers. + """ + + class TestBackend(BaseTestBackend): + preDeleteFn = None + + def registerPreDelete(self, preDeleteFn): + self.preDeleteFn = preDeleteFn + + def getSubscribers(self, nodeIdentifier): + return defer.succeed([OWNER]) + + def notifyDelete(service, nodeIdentifier, subscribers, + redirectURI=None): + self.assertEqual(SERVICE, service) + self.assertEqual('test', nodeIdentifier) + self.assertEqual([OWNER], subscribers) + self.assertIdentical(None, redirectURI) + d1.callback(None) + + d1 = defer.Deferred() + resource = backend.PubSubResourceFromBackend(TestBackend()) + resource.serviceJID = SERVICE + resource.pubsubService = pubsub.PubSubService() + resource.pubsubService.notifyDelete = notifyDelete + self.assertTrue(verifyObject(iwokkel.IPubSubResource, resource)) + self.assertNotIdentical(None, resource.backend.preDeleteFn) + + class TestNode: + implements(iidavoll.ILeafNode) + nodeIdentifier = 'test' + nodeType = 'leaf' + + data = {'node': TestNode()} + d2 = resource.backend.preDeleteFn(data) + return defer.DeferredList([d1, d2], fireOnOneErrback=1) + + + def test_preDeleteRedirect(self): + """ + Test pre-delete sending out notifications to subscribers. + """ + + uri = 'xmpp:%s?;node=test2' % (SERVICE.full(),) + + class TestBackend(BaseTestBackend): + preDeleteFn = None + + def registerPreDelete(self, preDeleteFn): + self.preDeleteFn = preDeleteFn + + def getSubscribers(self, nodeIdentifier): + return defer.succeed([OWNER]) + + def notifyDelete(service, nodeIdentifier, subscribers, + redirectURI=None): + self.assertEqual(SERVICE, service) + self.assertEqual('test', nodeIdentifier) + self.assertEqual([OWNER], subscribers) + self.assertEqual(uri, redirectURI) + d1.callback(None) + + d1 = defer.Deferred() + resource = backend.PubSubResourceFromBackend(TestBackend()) + resource.serviceJID = SERVICE + resource.pubsubService = pubsub.PubSubService() + resource.pubsubService.notifyDelete = notifyDelete + self.assertTrue(verifyObject(iwokkel.IPubSubResource, resource)) + self.assertNotIdentical(None, resource.backend.preDeleteFn) + + class TestNode: + implements(iidavoll.ILeafNode) + nodeIdentifier = 'test' + nodeType = 'leaf' + + data = {'node': TestNode(), + 'redirectURI': uri} + d2 = resource.backend.preDeleteFn(data) + return defer.DeferredList([d1, d2], fireOnOneErrback=1) + + + def test_unsubscribeNotSubscribed(self): + """ + Test unsubscription request when not subscribed. + """ + + class TestBackend(BaseTestBackend): + def unsubscribe(self, nodeIdentifier, subscriber, requestor): + return defer.fail(error.NotSubscribed()) + + def cb(e): + self.assertEquals('unexpected-request', e.condition) + + resource = backend.PubSubResourceFromBackend(TestBackend()) + request = pubsub.PubSubRequest() + request.sender = OWNER + request.recipient = SERVICE + request.nodeIdentifier = 'test' + request.subscriber = OWNER + d = resource.unsubscribe(request) + self.assertFailure(d, StanzaError) + d.addCallback(cb) + return d + + + def test_getInfo(self): + """ + Test retrieving node information. + """ + + class TestBackend(BaseTestBackend): + def getNodeType(self, nodeIdentifier): + return defer.succeed('leaf') + + def getNodeMetaData(self, nodeIdentifier): + return defer.succeed({'pubsub#persist_items': True}) + + def cb(info): + self.assertIn('type', info) + self.assertEquals('leaf', info['type']) + self.assertIn('meta-data', info) + self.assertEquals({'pubsub#persist_items': True}, info['meta-data']) + + resource = backend.PubSubResourceFromBackend(TestBackend()) + d = resource.getInfo(OWNER, SERVICE, 'test') + d.addCallback(cb) + return d + + + def test_getConfigurationOptions(self): + class TestBackend(BaseTestBackend): + nodeOptions = { + "pubsub#persist_items": + {"type": "boolean", + "label": "Persist items to storage"}, + "pubsub#deliver_payloads": + {"type": "boolean", + "label": "Deliver payloads with event notifications"} + } + + resource = backend.PubSubResourceFromBackend(TestBackend()) + options = resource.getConfigurationOptions() + self.assertIn("pubsub#persist_items", options) + + + def test_default(self): + class TestBackend(BaseTestBackend): + def getDefaultConfiguration(self, nodeType): + options = {"pubsub#persist_items": True, + "pubsub#deliver_payloads": True, + "pubsub#send_last_published_item": 'on_sub', + } + return defer.succeed(options) + + def cb(options): + self.assertEquals(True, options["pubsub#persist_items"]) + + resource = backend.PubSubResourceFromBackend(TestBackend()) + request = pubsub.PubSubRequest() + request.sender = OWNER + request.recipient = SERVICE + request.nodeType = 'leaf' + d = resource.default(request) + d.addCallback(cb) + return d diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/test/test_gateway.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/test/test_gateway.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,822 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2019 Jérôme Poisson + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +""" +Tests for L{idavoll.gateway}. + +Note that some tests are functional tests that require a running idavoll +service. +""" + +from StringIO import StringIO + +import simplejson + +from twisted.internet import defer +from twisted.trial import unittest +from twisted.web import error, http, http_headers, server +from twisted.web.test import requesthelper +from twisted.words.xish import domish +from twisted.words.protocols.jabber.jid import JID + +from sat_pubsub import gateway +from sat_pubsub.backend import BackendService +from sat_pubsub.memory_storage import Storage + +AGENT = "Idavoll Test Script" +NS_ATOM = "http://www.w3.org/2005/Atom" + +TEST_ENTRY = domish.Element((NS_ATOM, 'entry')) +TEST_ENTRY.addElement("id", + content="urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a") +TEST_ENTRY.addElement("title", content="Atom-Powered Robots Run Amok") +TEST_ENTRY.addElement("author").addElement("name", content="John Doe") +TEST_ENTRY.addElement("content", content="Some text.") + +baseURI = "http://localhost:8086/" +component = "pubsub" +componentJID = JID(component) +ownerJID = JID('owner@example.org') + +def _render(resource, request): + result = resource.render(request) + if isinstance(result, str): + request.write(result) + request.finish() + return defer.succeed(None) + elif result is server.NOT_DONE_YET: + if request.finished: + return defer.succeed(None) + else: + return request.notifyFinish() + else: + raise ValueError("Unexpected return value: %r" % (result,)) + + +class DummyRequest(requesthelper.DummyRequest): + + def __init__(self, *args, **kwargs): + requesthelper.DummyRequest.__init__(self, *args, **kwargs) + self.requestHeaders = http_headers.Headers() + + + +class GetServiceAndNodeTest(unittest.TestCase): + """ + Tests for {gateway.getServiceAndNode}. + """ + + def test_basic(self): + """ + getServiceAndNode parses an XMPP URI with node parameter. + """ + uri = b'xmpp:pubsub.example.org?;node=test' + service, nodeIdentifier = gateway.getServiceAndNode(uri) + self.assertEqual(JID(u'pubsub.example.org'), service) + self.assertEqual(u'test', nodeIdentifier) + + + def test_schemeEmpty(self): + """ + If the URI scheme is empty, an exception is raised. + """ + uri = b'pubsub.example.org' + self.assertRaises(gateway.XMPPURIParseError, + gateway.getServiceAndNode, uri) + + + def test_schemeNotXMPP(self): + """ + If the URI scheme is not 'xmpp', an exception is raised. + """ + uri = b'mailto:test@example.org' + self.assertRaises(gateway.XMPPURIParseError, + gateway.getServiceAndNode, uri) + + + def test_authorityPresent(self): + """ + If the URI has an authority component, an exception is raised. + """ + uri = b'xmpp://pubsub.example.org/' + self.assertRaises(gateway.XMPPURIParseError, + gateway.getServiceAndNode, uri) + + + def test_queryEmpty(self): + """ + If there is no query component, the nodeIdentifier is empty. + """ + uri = b'xmpp:pubsub.example.org' + service, nodeIdentifier = gateway.getServiceAndNode(uri) + + self.assertEqual(JID(u'pubsub.example.org'), service) + self.assertEqual(u'', nodeIdentifier) + + + def test_jidInvalid(self): + """ + If the JID from the path component is invalid, an exception is raised. + """ + uri = b'xmpp:@@pubsub.example.org?;node=test' + self.assertRaises(gateway.XMPPURIParseError, + gateway.getServiceAndNode, uri) + + + def test_pathEmpty(self): + """ + If there is no path component, an exception is raised. + """ + uri = b'xmpp:?node=test' + self.assertRaises(gateway.XMPPURIParseError, + gateway.getServiceAndNode, uri) + + + def test_nodeAbsent(self): + """ + If the node parameter is missing, the nodeIdentifier is empty. + """ + uri = b'xmpp:pubsub.example.org?' + service, nodeIdentifier = gateway.getServiceAndNode(uri) + + self.assertEqual(JID(u'pubsub.example.org'), service) + self.assertEqual(u'', nodeIdentifier) + + + +class GetXMPPURITest(unittest.TestCase): + """ + Tests for L{gateway.getXMPPURITest}. + """ + + def test_basic(self): + uri = gateway.getXMPPURI(JID(u'pubsub.example.org'), u'test') + self.assertEqual('xmpp:pubsub.example.org?;node=test', uri) + + +class CreateResourceTest(unittest.TestCase): + """ + Tests for L{gateway.CreateResource}. + """ + + def setUp(self): + self.backend = BackendService(Storage()) + self.resource = gateway.CreateResource(self.backend, componentJID, + ownerJID) + + + def test_get(self): + """ + The method GET is not supported. + """ + request = DummyRequest([b'']) + self.assertRaises(error.UnsupportedMethod, + _render, self.resource, request) + + + def test_post(self): + """ + Upon a POST, a new node is created and the URI returned. + """ + request = DummyRequest([b'']) + request.method = 'POST' + + def gotNodes(nodeIdentifiers, uri): + service, nodeIdentifier = gateway.getServiceAndNode(uri) + self.assertIn(nodeIdentifier, nodeIdentifiers) + + def rendered(result): + self.assertEqual('application/json', + request.outgoingHeaders['content-type']) + payload = simplejson.loads(b''.join(request.written)) + self.assertIn('uri', payload) + d = self.backend.getNodes() + d.addCallback(gotNodes, payload['uri']) + return d + + d = _render(self.resource, request) + d.addCallback(rendered) + return d + + + +class DeleteResourceTest(unittest.TestCase): + """ + Tests for L{gateway.DeleteResource}. + """ + + def setUp(self): + self.backend = BackendService(Storage()) + self.resource = gateway.DeleteResource(self.backend, componentJID, + ownerJID) + + + def test_get(self): + """ + The method GET is not supported. + """ + request = DummyRequest([b'']) + self.assertRaises(error.UnsupportedMethod, + _render, self.resource, request) + + + def test_post(self): + """ + Upon a POST, a new node is created and the URI returned. + """ + request = DummyRequest([b'']) + request.method = b'POST' + + def rendered(result): + self.assertEqual(http.NO_CONTENT, request.responseCode) + + def nodeCreated(nodeIdentifier): + uri = gateway.getXMPPURI(componentJID, nodeIdentifier) + request.args[b'uri'] = [uri] + request.content = StringIO(b'') + + return _render(self.resource, request) + + d = self.backend.createNode(u'test', ownerJID) + d.addCallback(nodeCreated) + d.addCallback(rendered) + return d + + + def test_postWithRedirect(self): + """ + Upon a POST, a new node is created and the URI returned. + """ + request = DummyRequest([b'']) + request.method = b'POST' + otherNodeURI = b'xmpp:pubsub.example.org?node=other' + + def rendered(result): + self.assertEqual(http.NO_CONTENT, request.responseCode) + self.assertEqual(1, len(deletes)) + nodeIdentifier, owner, redirectURI = deletes[-1] + self.assertEqual(otherNodeURI, redirectURI) + + def nodeCreated(nodeIdentifier): + uri = gateway.getXMPPURI(componentJID, nodeIdentifier) + request.args[b'uri'] = [uri] + payload = {b'redirect_uri': otherNodeURI} + body = simplejson.dumps(payload) + request.content = StringIO(body) + return _render(self.resource, request) + + def deleteNode(nodeIdentifier, owner, redirectURI): + deletes.append((nodeIdentifier, owner, redirectURI)) + return defer.succeed(nodeIdentifier) + + deletes = [] + self.patch(self.backend, 'deleteNode', deleteNode) + d = self.backend.createNode(u'test', ownerJID) + d.addCallback(nodeCreated) + d.addCallback(rendered) + return d + + + def test_postUnknownNode(self): + """ + If the node to be deleted is unknown, 404 Not Found is returned. + """ + request = DummyRequest([b'']) + request.method = b'POST' + + def rendered(result): + self.assertEqual(http.NOT_FOUND, request.responseCode) + + uri = gateway.getXMPPURI(componentJID, u'unknown') + request.args[b'uri'] = [uri] + request.content = StringIO(b'') + + d = _render(self.resource, request) + d.addCallback(rendered) + return d + + + def test_postMalformedXMPPURI(self): + """ + If the XMPP URI is malformed, Bad Request is returned. + """ + request = DummyRequest([b'']) + request.method = b'POST' + + def rendered(result): + self.assertEqual(http.BAD_REQUEST, request.responseCode) + + uri = 'xmpp:@@@@' + request.args[b'uri'] = [uri] + request.content = StringIO(b'') + + d = _render(self.resource, request) + d.addCallback(rendered) + return d + + + def test_postURIMissing(self): + """ + If no URI is passed, 400 Bad Request is returned. + """ + request = DummyRequest([b'']) + request.method = b'POST' + + def rendered(result): + self.assertEqual(http.BAD_REQUEST, request.responseCode) + + request.content = StringIO(b'') + + d = _render(self.resource, request) + d.addCallback(rendered) + return d + + + +class CallbackResourceTest(unittest.TestCase): + """ + Tests for L{gateway.CallbackResource}. + """ + + def setUp(self): + self.callbackEvents = [] + self.resource = gateway.CallbackResource(self._callback) + + + def _callback(self, payload, headers): + self.callbackEvents.append((payload, headers)) + + + def test_get(self): + """ + The method GET is not supported. + """ + request = DummyRequest([b'']) + self.assertRaises(error.UnsupportedMethod, + _render, self.resource, request) + + + def test_post(self): + """ + The body posted is passed to the callback. + """ + request = DummyRequest([b'']) + request.method = 'POST' + request.content = StringIO(b'') + + def rendered(result): + self.assertEqual(1, len(self.callbackEvents)) + payload, headers = self.callbackEvents[-1] + self.assertEqual('root', payload.name) + + self.assertEqual(http.NO_CONTENT, request.responseCode) + self.assertFalse(b''.join(request.written)) + + d = _render(self.resource, request) + d.addCallback(rendered) + return d + + + def test_postEvent(self): + """ + If the Event header is set, the payload is empty and the header passed. + """ + request = DummyRequest([b'']) + request.method = 'POST' + request.requestHeaders.addRawHeader(b'Event', b'DELETE') + request.content = StringIO(b'') + + def rendered(result): + self.assertEqual(1, len(self.callbackEvents)) + payload, headers = self.callbackEvents[-1] + self.assertIdentical(None, payload) + self.assertEqual(['DELETE'], headers.getRawHeaders(b'Event')) + self.assertFalse(b''.join(request.written)) + + d = _render(self.resource, request) + d.addCallback(rendered) + return d + + + +class GatewayTest(unittest.TestCase): + timeout = 2 + + def setUp(self): + self.client = gateway.GatewayClient(baseURI) + self.client.startService() + self.addCleanup(self.client.stopService) + + def trapConnectionRefused(failure): + from twisted.internet.error import ConnectionRefusedError + failure.trap(ConnectionRefusedError) + raise unittest.SkipTest("Gateway to test against is not available") + + def trapNotFound(failure): + from twisted.web.error import Error + failure.trap(Error) + + d = self.client.ping() + d.addErrback(trapConnectionRefused) + d.addErrback(trapNotFound) + return d + + + def tearDown(self): + return self.client.stopService() + + + def test_create(self): + + def cb(response): + self.assertIn('uri', response) + + d = self.client.create() + d.addCallback(cb) + return d + + def test_publish(self): + + def cb(response): + self.assertIn('uri', response) + + d = self.client.publish(TEST_ENTRY) + d.addCallback(cb) + return d + + def test_publishExistingNode(self): + + def cb2(response, xmppURI): + self.assertEquals(xmppURI, response['uri']) + + def cb1(response): + xmppURI = response['uri'] + d = self.client.publish(TEST_ENTRY, xmppURI) + d.addCallback(cb2, xmppURI) + return d + + d = self.client.create() + d.addCallback(cb1) + return d + + def test_publishNonExisting(self): + def cb(err): + self.assertEqual('404', err.status) + + d = self.client.publish(TEST_ENTRY, 'xmpp:%s?node=test' % component) + self.assertFailure(d, error.Error) + d.addCallback(cb) + return d + + def test_delete(self): + def cb(response): + xmppURI = response['uri'] + d = self.client.delete(xmppURI) + return d + + d = self.client.create() + d.addCallback(cb) + return d + + def test_deleteWithRedirect(self): + def cb(response): + xmppURI = response['uri'] + redirectURI = 'xmpp:%s?node=test' % component + d = self.client.delete(xmppURI, redirectURI) + return d + + d = self.client.create() + d.addCallback(cb) + return d + + def test_deleteNotification(self): + def onNotification(data, headers): + try: + self.assertTrue(headers.hasHeader('Event')) + self.assertEquals(['DELETED'], headers.getRawHeaders('Event')) + self.assertFalse(headers.hasHeader('Link')) + except: + self.client.deferred.errback() + else: + self.client.deferred.callback(None) + + def cb(response): + xmppURI = response['uri'] + d = self.client.subscribe(xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb2(xmppURI): + d = self.client.delete(xmppURI) + return d + + self.client.callback = onNotification + self.client.deferred = defer.Deferred() + d = self.client.create() + d.addCallback(cb) + d.addCallback(cb2) + return defer.gatherResults([d, self.client.deferred]) + + def test_deleteNotificationWithRedirect(self): + redirectURI = 'xmpp:%s?node=test' % component + + def onNotification(data, headers): + try: + self.assertTrue(headers.hasHeader('Event')) + self.assertEquals(['DELETED'], headers.getRawHeaders('Event')) + self.assertEquals(['<%s>; rel=alternate' % redirectURI], + headers.getRawHeaders('Link')) + except: + self.client.deferred.errback() + else: + self.client.deferred.callback(None) + + def cb(response): + xmppURI = response['uri'] + d = self.client.subscribe(xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb2(xmppURI): + d = self.client.delete(xmppURI, redirectURI) + return d + + self.client.callback = onNotification + self.client.deferred = defer.Deferred() + d = self.client.create() + d.addCallback(cb) + d.addCallback(cb2) + return defer.gatherResults([d, self.client.deferred]) + + def test_list(self): + d = self.client.listNodes() + return d + + def test_subscribe(self): + def cb(response): + xmppURI = response['uri'] + d = self.client.subscribe(xmppURI) + return d + + d = self.client.create() + d.addCallback(cb) + return d + + def test_subscribeGetNotification(self): + + def onNotification(data, headers): + self.client.deferred.callback(None) + + def cb(response): + xmppURI = response['uri'] + d = self.client.subscribe(xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb2(xmppURI): + d = self.client.publish(TEST_ENTRY, xmppURI) + return d + + + self.client.callback = onNotification + self.client.deferred = defer.Deferred() + d = self.client.create() + d.addCallback(cb) + d.addCallback(cb2) + return defer.gatherResults([d, self.client.deferred]) + + + def test_subscribeTwiceGetNotification(self): + + def onNotification1(data, headers): + d = client1.stopService() + d.chainDeferred(client1.deferred) + + def onNotification2(data, headers): + d = client2.stopService() + d.chainDeferred(client2.deferred) + + def cb(response): + xmppURI = response['uri'] + d = client1.subscribe(xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb2(xmppURI): + d = client2.subscribe(xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb3(xmppURI): + d = self.client.publish(TEST_ENTRY, xmppURI) + return d + + + client1 = gateway.GatewayClient(baseURI, callbackPort=8088) + client1.startService() + client1.callback = onNotification1 + client1.deferred = defer.Deferred() + client2 = gateway.GatewayClient(baseURI, callbackPort=8089) + client2.startService() + client2.callback = onNotification2 + client2.deferred = defer.Deferred() + + d = self.client.create() + d.addCallback(cb) + d.addCallback(cb2) + d.addCallback(cb3) + dl = defer.gatherResults([d, client1.deferred, client2.deferred]) + return dl + + + def test_subscribeGetDelayedNotification(self): + + def onNotification(data, headers): + self.client.deferred.callback(None) + + def cb(response): + xmppURI = response['uri'] + self.assertNot(self.client.deferred.called) + d = self.client.publish(TEST_ENTRY, xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb2(xmppURI): + d = self.client.subscribe(xmppURI) + return d + + + self.client.callback = onNotification + self.client.deferred = defer.Deferred() + d = self.client.create() + d.addCallback(cb) + d.addCallback(cb2) + return defer.gatherResults([d, self.client.deferred]) + + def test_subscribeGetDelayedNotification2(self): + """ + Test that subscribing as second results in a notification being sent. + """ + + def onNotification1(data, headers): + client1.deferred.callback(None) + client1.stopService() + + def onNotification2(data, headers): + client2.deferred.callback(None) + client2.stopService() + + def cb(response): + xmppURI = response['uri'] + self.assertNot(client1.deferred.called) + self.assertNot(client2.deferred.called) + d = self.client.publish(TEST_ENTRY, xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb2(xmppURI): + d = client1.subscribe(xmppURI) + d.addCallback(lambda _: xmppURI) + return d + + def cb3(xmppURI): + d = client2.subscribe(xmppURI) + return d + + client1 = gateway.GatewayClient(baseURI, callbackPort=8088) + client1.startService() + client1.callback = onNotification1 + client1.deferred = defer.Deferred() + client2 = gateway.GatewayClient(baseURI, callbackPort=8089) + client2.startService() + client2.callback = onNotification2 + client2.deferred = defer.Deferred() + + + d = self.client.create() + d.addCallback(cb) + d.addCallback(cb2) + d.addCallback(cb3) + dl = defer.gatherResults([d, client1.deferred, client2.deferred]) + return dl + + + def test_subscribeNonExisting(self): + def cb(err): + self.assertEqual('403', err.status) + + d = self.client.subscribe('xmpp:%s?node=test' % component) + self.assertFailure(d, error.Error) + d.addCallback(cb) + return d + + + def test_subscribeRootGetNotification(self): + + def clean(rootNode): + return self.client.unsubscribe(rootNode) + + def onNotification(data, headers): + self.client.deferred.callback(None) + + def cb(response): + xmppURI = response['uri'] + jid, nodeIdentifier = gateway.getServiceAndNode(xmppURI) + rootNode = gateway.getXMPPURI(jid, '') + + d = self.client.subscribe(rootNode) + d.addCallback(lambda _: self.addCleanup(clean, rootNode)) + d.addCallback(lambda _: xmppURI) + return d + + def cb2(xmppURI): + return self.client.publish(TEST_ENTRY, xmppURI) + + + self.client.callback = onNotification + self.client.deferred = defer.Deferred() + d = self.client.create() + d.addCallback(cb) + d.addCallback(cb2) + return defer.gatherResults([d, self.client.deferred]) + + + def test_unsubscribeNonExisting(self): + def cb(err): + self.assertEqual('403', err.status) + + d = self.client.unsubscribe('xmpp:%s?node=test' % component) + self.assertFailure(d, error.Error) + d.addCallback(cb) + return d + + + def test_items(self): + def cb(response): + xmppURI = response['uri'] + d = self.client.items(xmppURI) + return d + + d = self.client.publish(TEST_ENTRY) + d.addCallback(cb) + return d + + + def test_itemsMaxItems(self): + def cb(response): + xmppURI = response['uri'] + d = self.client.items(xmppURI, 2) + return d + + d = self.client.publish(TEST_ENTRY) + d.addCallback(cb) + return d diff -r 105a0772eedd -r c56a728412f1 sat_pubsub/test/test_storage.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/sat_pubsub/test/test_storage.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,642 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2019 Jérôme Poisson + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +""" +Tests for L{idavoll.memory_storage} and L{idavoll.pgsql_storage}. +""" + +from zope.interface.verify import verifyObject +from twisted.trial import unittest +from twisted.words.protocols.jabber import jid +from twisted.internet import defer +from twisted.words.xish import domish + +from sat_pubsub import error, iidavoll, const + +OWNER = jid.JID('owner@example.com/Work') +SUBSCRIBER = jid.JID('subscriber@example.com/Home') +SUBSCRIBER_NEW = jid.JID('new@example.com/Home') +SUBSCRIBER_TO_BE_DELETED = jid.JID('to_be_deleted@example.com/Home') +SUBSCRIBER_PENDING = jid.JID('pending@example.com/Home') +PUBLISHER = jid.JID('publisher@example.com') +ITEM = domish.Element((None, 'item')) +ITEM['id'] = 'current' +ITEM.addElement(('testns', 'test'), content=u'Test \u2083 item') +ITEM_NEW = domish.Element((None, 'item')) +ITEM_NEW['id'] = 'new' +ITEM_NEW.addElement(('testns', 'test'), content=u'Test \u2083 item') +ITEM_UPDATED = domish.Element((None, 'item')) +ITEM_UPDATED['id'] = 'current' +ITEM_UPDATED.addElement(('testns', 'test'), content=u'Test \u2084 item') +ITEM_TO_BE_DELETED = domish.Element((None, 'item')) +ITEM_TO_BE_DELETED['id'] = 'to-be-deleted' +ITEM_TO_BE_DELETED.addElement(('testns', 'test'), content=u'Test \u2083 item') + +def decode(object): + if isinstance(object, str): + object = object.decode('utf-8') + return object + + + +class StorageTests: + + def _assignTestNode(self, node): + self.node = node + + + def setUp(self): + d = self.s.getNode('pre-existing') + d.addCallback(self._assignTestNode) + return d + + + def test_interfaceIStorage(self): + self.assertTrue(verifyObject(iidavoll.IStorage, self.s)) + + + def test_interfaceINode(self): + self.assertTrue(verifyObject(iidavoll.INode, self.node)) + + + def test_interfaceILeafNode(self): + self.assertTrue(verifyObject(iidavoll.ILeafNode, self.node)) + + + def test_getNode(self): + return self.s.getNode('pre-existing') + + + def test_getNonExistingNode(self): + d = self.s.getNode('non-existing') + self.assertFailure(d, error.NodeNotFound) + return d + + + def test_getNodeIDs(self): + def cb(nodeIdentifiers): + self.assertIn('pre-existing', nodeIdentifiers) + self.assertNotIn('non-existing', nodeIdentifiers) + + return self.s.getNodeIds().addCallback(cb) + + + def test_createExistingNode(self): + config = self.s.getDefaultConfiguration('leaf') + config['pubsub#node_type'] = 'leaf' + d = self.s.createNode('pre-existing', OWNER, config) + self.assertFailure(d, error.NodeExists) + return d + + + def test_createNode(self): + def cb(void): + d = self.s.getNode('new 1') + return d + + config = self.s.getDefaultConfiguration('leaf') + config['pubsub#node_type'] = 'leaf' + d = self.s.createNode('new 1', OWNER, config) + d.addCallback(cb) + return d + + + def test_createNodeChangingConfig(self): + """ + The configuration passed to createNode must be free to be changed. + """ + def cb(result): + node1, node2 = result + self.assertTrue(node1.getConfiguration()['pubsub#persist_items']) + + config = { + "pubsub#persist_items": True, + "pubsub#deliver_payloads": True, + "pubsub#send_last_published_item": 'on_sub', + "pubsub#node_type": 'leaf', + "pubsub#access_model": 'open', + const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_OPEN + } + + def unsetPersistItems(_): + config["pubsub#persist_items"] = False + + d = defer.succeed(None) + d.addCallback(lambda _: self.s.createNode('new 1', OWNER, config)) + d.addCallback(unsetPersistItems) + d.addCallback(lambda _: self.s.createNode('new 2', OWNER, config)) + d.addCallback(lambda _: defer.gatherResults([ + self.s.getNode('new 1'), + self.s.getNode('new 2')])) + d.addCallback(cb) + return d + + + def test_deleteNonExistingNode(self): + d = self.s.deleteNode('non-existing') + self.assertFailure(d, error.NodeNotFound) + return d + + + def test_deleteNode(self): + def cb(void): + d = self.s.getNode('to-be-deleted') + self.assertFailure(d, error.NodeNotFound) + return d + + d = self.s.deleteNode('to-be-deleted') + d.addCallback(cb) + return d + + + def test_getAffiliations(self): + def cb(affiliations): + self.assertIn(('pre-existing', 'owner'), affiliations) + + d = self.s.getAffiliations(OWNER) + d.addCallback(cb) + return d + + + def test_getSubscriptions(self): + def cb(subscriptions): + found = False + for subscription in subscriptions: + if (subscription.nodeIdentifier == 'pre-existing' and + subscription.subscriber == SUBSCRIBER and + subscription.state == 'subscribed'): + found = True + self.assertTrue(found) + + d = self.s.getSubscriptions(SUBSCRIBER) + d.addCallback(cb) + return d + + + # Node tests + + def test_getType(self): + self.assertEqual(self.node.getType(), 'leaf') + + + def test_getConfiguration(self): + config = self.node.getConfiguration() + self.assertIn('pubsub#persist_items', config.iterkeys()) + self.assertIn('pubsub#deliver_payloads', config.iterkeys()) + self.assertEqual(config['pubsub#persist_items'], True) + self.assertEqual(config['pubsub#deliver_payloads'], True) + + + def test_setConfiguration(self): + def getConfig(node): + d = node.setConfiguration({'pubsub#persist_items': False}) + d.addCallback(lambda _: node) + return d + + def checkObjectConfig(node): + config = node.getConfiguration() + self.assertEqual(config['pubsub#persist_items'], False) + + def getNode(void): + return self.s.getNode('to-be-reconfigured') + + def checkStorageConfig(node): + config = node.getConfiguration() + self.assertEqual(config['pubsub#persist_items'], False) + + d = self.s.getNode('to-be-reconfigured') + d.addCallback(getConfig) + d.addCallback(checkObjectConfig) + d.addCallback(getNode) + d.addCallback(checkStorageConfig) + return d + + + def test_getMetaData(self): + metaData = self.node.getMetaData() + for key, value in self.node.getConfiguration().iteritems(): + self.assertIn(key, metaData.iterkeys()) + self.assertEqual(value, metaData[key]) + self.assertIn('pubsub#node_type', metaData.iterkeys()) + self.assertEqual(metaData['pubsub#node_type'], 'leaf') + + + def test_getAffiliation(self): + def cb(affiliation): + self.assertEqual(affiliation, 'owner') + + d = self.node.getAffiliation(OWNER) + d.addCallback(cb) + return d + + + def test_getNonExistingAffiliation(self): + def cb(affiliation): + self.assertEqual(affiliation, None) + + d = self.node.getAffiliation(SUBSCRIBER) + d.addCallback(cb) + return d + + + def test_addSubscription(self): + def cb1(void): + return self.node.getSubscription(SUBSCRIBER_NEW) + + def cb2(subscription): + self.assertEqual(subscription.state, 'pending') + + d = self.node.addSubscription(SUBSCRIBER_NEW, 'pending', {}) + d.addCallback(cb1) + d.addCallback(cb2) + return d + + + def test_addExistingSubscription(self): + d = self.node.addSubscription(SUBSCRIBER, 'pending', {}) + self.assertFailure(d, error.SubscriptionExists) + return d + + + def test_getSubscription(self): + def cb(subscriptions): + self.assertEquals(subscriptions[0].state, 'subscribed') + self.assertEquals(subscriptions[1].state, 'pending') + self.assertEquals(subscriptions[2], None) + + d = defer.gatherResults([self.node.getSubscription(SUBSCRIBER), + self.node.getSubscription(SUBSCRIBER_PENDING), + self.node.getSubscription(OWNER)]) + d.addCallback(cb) + return d + + + def test_removeSubscription(self): + return self.node.removeSubscription(SUBSCRIBER_TO_BE_DELETED) + + + def test_removeNonExistingSubscription(self): + d = self.node.removeSubscription(OWNER) + self.assertFailure(d, error.NotSubscribed) + return d + + + def test_getNodeSubscriptions(self): + def extractSubscribers(subscriptions): + return [subscription.subscriber for subscription in subscriptions] + + def cb(subscribers): + self.assertIn(SUBSCRIBER, subscribers) + self.assertNotIn(SUBSCRIBER_PENDING, subscribers) + self.assertNotIn(OWNER, subscribers) + + d = self.node.getSubscriptions('subscribed') + d.addCallback(extractSubscribers) + d.addCallback(cb) + return d + + + def test_isSubscriber(self): + def cb(subscribed): + self.assertEquals(subscribed[0][1], True) + self.assertEquals(subscribed[1][1], True) + self.assertEquals(subscribed[2][1], False) + self.assertEquals(subscribed[3][1], False) + + d = defer.DeferredList([self.node.isSubscribed(SUBSCRIBER), + self.node.isSubscribed(SUBSCRIBER.userhostJID()), + self.node.isSubscribed(SUBSCRIBER_PENDING), + self.node.isSubscribed(OWNER)]) + d.addCallback(cb) + return d + + + def test_storeItems(self): + def cb1(void): + return self.node.getItemsById("", False, ['new']) + + def cb2(result): + self.assertEqual(ITEM_NEW.toXml(), result[0].toXml()) + + d = self.node.storeItems([(const.VAL_AMODEL_DEFAULT, {}, ITEM_NEW)], PUBLISHER) + d.addCallback(cb1) + d.addCallback(cb2) + return d + + + def test_storeUpdatedItems(self): + def cb1(void): + return self.node.getItemsById("", False, ['current']) + + def cb2(result): + self.assertEqual(ITEM_UPDATED.toXml(), result[0].toXml()) + + d = self.node.storeItems([(const.VAL_AMODEL_DEFAULT, {}, ITEM_UPDATED)], PUBLISHER) + d.addCallback(cb1) + d.addCallback(cb2) + return d + + + def test_removeItems(self): + def cb1(result): + self.assertEqual(['to-be-deleted'], result) + return self.node.getItemsById("", False, ['to-be-deleted']) + + def cb2(result): + self.assertEqual(0, len(result)) + + d = self.node.removeItems(['to-be-deleted']) + d.addCallback(cb1) + d.addCallback(cb2) + return d + + + def test_removeNonExistingItems(self): + def cb(result): + self.assertEqual([], result) + + d = self.node.removeItems(['non-existing']) + d.addCallback(cb) + return d + + + def test_getItems(self): + def cb(result): + items = [item.toXml() for item in result] + self.assertIn(ITEM.toXml(), items) + d = self.node.getItems("", False) + d.addCallback(cb) + return d + + + def test_lastItem(self): + def cb(result): + self.assertEqual(1, len(result)) + self.assertEqual(ITEM.toXml(), result[0].toXml()) + + d = self.node.getItems("", False, 1) + d.addCallback(cb) + return d + + + def test_getItemsById(self): + def cb(result): + self.assertEqual(1, len(result)) + + d = self.node.getItemsById("", False, ['current']) + d.addCallback(cb) + return d + + + def test_getNonExistingItemsById(self): + def cb(result): + self.assertEqual(0, len(result)) + + d = self.node.getItemsById("", False, ['non-existing']) + d.addCallback(cb) + return d + + + def test_purge(self): + def cb1(node): + d = node.purge() + d.addCallback(lambda _: node) + return d + + def cb2(node): + return node.getItems("", False) + + def cb3(result): + self.assertEqual([], result) + + d = self.s.getNode('to-be-purged') + d.addCallback(cb1) + d.addCallback(cb2) + d.addCallback(cb3) + return d + + + def test_getNodeAffilatiations(self): + def cb1(node): + return node.getAffiliations() + + def cb2(affiliations): + affiliations = dict(((a[0].full(), a[1]) for a in affiliations)) + self.assertEquals(affiliations[OWNER.userhost()], 'owner') + + d = self.s.getNode('pre-existing') + d.addCallback(cb1) + d.addCallback(cb2) + return d + + + +class MemoryStorageStorageTestCase(unittest.TestCase, StorageTests): + + def setUp(self): + from sat_pubsub.memory_storage import Storage, PublishedItem, LeafNode + from sat_pubsub.memory_storage import Subscription + + defaultConfig = Storage.defaultConfig['leaf'] + + self.s = Storage() + self.s._nodes['pre-existing'] = \ + LeafNode('pre-existing', OWNER, defaultConfig) + self.s._nodes['to-be-deleted'] = \ + LeafNode('to-be-deleted', OWNER, None) + self.s._nodes['to-be-reconfigured'] = \ + LeafNode('to-be-reconfigured', OWNER, defaultConfig) + self.s._nodes['to-be-purged'] = \ + LeafNode('to-be-purged', OWNER, None) + + subscriptions = self.s._nodes['pre-existing']._subscriptions + subscriptions[SUBSCRIBER.full()] = Subscription('pre-existing', + SUBSCRIBER, + 'subscribed') + subscriptions[SUBSCRIBER_TO_BE_DELETED.full()] = \ + Subscription('pre-existing', SUBSCRIBER_TO_BE_DELETED, + 'subscribed') + subscriptions[SUBSCRIBER_PENDING.full()] = \ + Subscription('pre-existing', SUBSCRIBER_PENDING, + 'pending') + + item = PublishedItem(ITEM_TO_BE_DELETED, PUBLISHER) + self.s._nodes['pre-existing']._items['to-be-deleted'] = item + self.s._nodes['pre-existing']._itemlist.append(item) + self.s._nodes['to-be-purged']._items['to-be-deleted'] = item + self.s._nodes['to-be-purged']._itemlist.append(item) + item = PublishedItem(ITEM, PUBLISHER) + self.s._nodes['pre-existing']._items['current'] = item + self.s._nodes['pre-existing']._itemlist.append(item) + + return StorageTests.setUp(self) + + + +class PgsqlStorageStorageTestCase(unittest.TestCase, StorageTests): + + dbpool = None + + def setUp(self): + from sat_pubsub.pgsql_storage import Storage + from twisted.enterprise import adbapi + if self.dbpool is None: + self.__class__.dbpool = adbapi.ConnectionPool('psycopg2', + database='pubsub_test', + cp_reconnect=True, + client_encoding='utf-8', + connection_factory=NamedTupleConnection, + ) + self.s = Storage(self.dbpool) + self.dbpool.start() + d = self.dbpool.runInteraction(self.init) + d.addCallback(lambda _: StorageTests.setUp(self)) + return d + + + def tearDown(self): + d = self.dbpool.runInteraction(self.cleandb) + return d.addCallback(lambda _: self.dbpool.close()) + + + def init(self, cursor): + self.cleandb(cursor) + cursor.execute("""INSERT INTO nodes + (node, node_type, persist_items) + VALUES ('pre-existing', 'leaf', TRUE)""") + cursor.execute("""INSERT INTO nodes (node) VALUES ('to-be-deleted')""") + cursor.execute("""INSERT INTO nodes (node) VALUES ('to-be-reconfigured')""") + cursor.execute("""INSERT INTO nodes (node) VALUES ('to-be-purged')""") + cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", + (OWNER.userhost(),)) + cursor.execute("""INSERT INTO affiliations + (node_id, entity_id, affiliation) + SELECT node_id, entity_id, 'owner' + FROM nodes, entities + WHERE node='pre-existing' AND jid=%s""", + (OWNER.userhost(),)) + cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", + (SUBSCRIBER.userhost(),)) + cursor.execute("""INSERT INTO subscriptions + (node_id, entity_id, resource, state) + SELECT node_id, entity_id, %s, 'subscribed' + FROM nodes, entities + WHERE node='pre-existing' AND jid=%s""", + (SUBSCRIBER.resource, + SUBSCRIBER.userhost())) + cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", + (SUBSCRIBER_TO_BE_DELETED.userhost(),)) + cursor.execute("""INSERT INTO subscriptions + (node_id, entity_id, resource, state) + SELECT node_id, entity_id, %s, 'subscribed' + FROM nodes, entities + WHERE node='pre-existing' AND jid=%s""", + (SUBSCRIBER_TO_BE_DELETED.resource, + SUBSCRIBER_TO_BE_DELETED.userhost())) + cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", + (SUBSCRIBER_PENDING.userhost(),)) + cursor.execute("""INSERT INTO subscriptions + (node_id, entity_id, resource, state) + SELECT node_id, entity_id, %s, 'pending' + FROM nodes, entities + WHERE node='pre-existing' AND jid=%s""", + (SUBSCRIBER_PENDING.resource, + SUBSCRIBER_PENDING.userhost())) + cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", + (PUBLISHER.userhost(),)) + cursor.execute("""INSERT INTO items + (node_id, publisher, item, data, created) + SELECT node_id, %s, 'to-be-deleted', %s, + now() - interval '1 day' + FROM nodes + WHERE node='pre-existing'""", + (PUBLISHER.userhost(), + ITEM_TO_BE_DELETED.toXml())) + cursor.execute("""INSERT INTO items (node_id, publisher, item, data) + SELECT node_id, %s, 'to-be-deleted', %s + FROM nodes + WHERE node='to-be-purged'""", + (PUBLISHER.userhost(), + ITEM_TO_BE_DELETED.toXml())) + cursor.execute("""INSERT INTO items (node_id, publisher, item, data) + SELECT node_id, %s, 'current', %s + FROM nodes + WHERE node='pre-existing'""", + (PUBLISHER.userhost(), + ITEM.toXml())) + + + def cleandb(self, cursor): + cursor.execute("""DELETE FROM nodes WHERE node in + ('non-existing', 'pre-existing', 'to-be-deleted', + 'new 1', 'new 2', 'new 3', 'to-be-reconfigured', + 'to-be-purged')""") + cursor.execute("""DELETE FROM entities WHERE jid=%s""", + (OWNER.userhost(),)) + cursor.execute("""DELETE FROM entities WHERE jid=%s""", + (SUBSCRIBER.userhost(),)) + cursor.execute("""DELETE FROM entities WHERE jid=%s""", + (SUBSCRIBER_NEW.userhost(),)) + cursor.execute("""DELETE FROM entities WHERE jid=%s""", + (SUBSCRIBER_TO_BE_DELETED.userhost(),)) + cursor.execute("""DELETE FROM entities WHERE jid=%s""", + (SUBSCRIBER_PENDING.userhost(),)) + cursor.execute("""DELETE FROM entities WHERE jid=%s""", + (PUBLISHER.userhost(),)) + + +try: + import psycopg2 + psycopg2 + from psycopg2.extras import NamedTupleConnection +except ImportError: + PgsqlStorageStorageTestCase.skip = "psycopg2 not available" diff -r 105a0772eedd -r c56a728412f1 setup.py --- a/setup.py Wed Jul 24 19:26:43 2019 +0200 +++ b/setup.py Fri Aug 16 12:00:02 2019 +0200 @@ -1,8 +1,9 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (C) 2011-2014 Jérôme Poisson +# SAT: an XMPP client +# Copyright (C) 2009-2016 Jérôme Poisson (goffi@goffi.org) +# Copyright (C) 2013-2016 Adrien Cossa (souliane@mailoo.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 @@ -17,85 +18,60 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed -# by the original license. - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. +from setuptools import setup, find_packages +import os -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -import sys -from setuptools import setup -from src import __version__ - - -# seen here: http://stackoverflow.com/questions/7275295 -try: - from setuptools.command import egg_info - egg_info.write_toplevel_names -except (ImportError, AttributeError): - pass -else: - def _top_level_package(name): - return name.split('.', 1)[0] - - def _hacked_write_toplevel_names(cmd, basename, filename): - pkgs = dict.fromkeys( - [_top_level_package(k) - for k in cmd.distribution.iter_distribution_names() - if _top_level_package(k) != "twisted" - ] - ) - cmd.write_file("top-level names", filename, '\n'.join(pkgs) + '\n') - - egg_info.write_toplevel_names = _hacked_write_toplevel_names - +NAME = 'sat_pubsub' install_requires = [ 'wokkel >= 0.7.1', + 'psycopg2', 'simplejson', + 'uuid', + 'sat_tmp', ] -if sys.version_info < (2, 5): - install_requires.append('uuid') + +with open(os.path.join(NAME, 'VERSION')) as f: + VERSION = f.read().strip() +is_dev_version = VERSION.endswith('D') + + +def sat_dev_version(): + """Use mercurial data to compute version""" + def version_scheme(version): + return VERSION.replace('D', '.dev0') + + def local_scheme(version): + return "+{rev}.{distance}".format( + rev=version.node[1:], + distance=version.distance) + + return {'version_scheme': version_scheme, + 'local_scheme': local_scheme} + -setup(name='sat_pubsub', - version=__version__, - description=u'XMPP Publish-Subscribe Service Component, build for the need of the « Salut à Toi » project', - maintainer='Jérôme Poisson', - maintainer_email='goffi@goffi.org', - url='http://repos.goffi.org/sat_pubsub', - license='AGPLv3+', - package_dir={'sat_pubsub': 'src', - 'twisted': 'src/twisted'}, - packages=[ - 'sat_pubsub', - 'sat_pubsub.test', - 'twisted.plugins', - ], - package_data={'twisted.plugins': ['src/twisted/plugins/pubsub.py']}, - data_files=[('share/sat_pubsub', ['db/pubsub.sql'])], - zip_safe=False, +setup(name=NAME, + version=VERSION, + description=u'XMPP Publish-Subscribe Service Component, build for the need of ' + u'the « Salut à Toi » project', + author='Association « Salut à Toi »', + author_email='goffi@goffi.org', + url='https://salut-a-toi.org', + classifiers=['Development Status :: 5', + 'Framework :: Twisted', + 'License :: OSI Approved :: GNU Affero General Public License v3 ' + 'or later (AGPLv3+)', + 'Operating System :: POSIX :: Linux', + 'Topic :: Communications :: Chat'], + packages=find_packages() + ['twisted.plugins'], + data_files=[(os.path.join('share/doc', NAME), + ['CHANGELOG', 'COPYING', 'README']), + ], + zip_safe=True, + setup_requires=['setuptools_scm'] if is_dev_version else [], + use_scm_version=sat_dev_version if is_dev_version else False, install_requires=install_requires, -) + package_data={'sat_pubsub': ['VERSION']}, + python_requires='~=2.7', + ) diff -r 105a0772eedd -r c56a728412f1 src/__init__.py --- a/src/__init__.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,61 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2019 Jérôme Poisson -# Copyright (c) 2013-2016 Adrien Cossa -# Copyright (c) 2003-2011 Ralph Meijer -# -# -# 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 . -# -- -# -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. -# -# -- -# -# Here is a copy of the original license: -# -# Copyright (c) 2003-2011 Ralph Meijer -# -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: -# -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. -# -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - -""" -SàT PubSub, a generic XMPP publish-subscribe service. -""" - -__version__ = '0.3.0a1' - -# TODO: remove this when changes are merged in Wokkel -from sat_tmp.wokkel import install -install() diff -r 105a0772eedd -r c56a728412f1 src/backend.py --- a/src/backend.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1792 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- -# -# Copyright (c) 2012-2019 Jérôme Poisson -# Copyright (c) 2013-2016 Adrien Cossa -# Copyright (c) 2003-2011 Ralph Meijer - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -""" -Generic publish-subscribe backend. - -This module implements a generic publish-subscribe backend service with -business logic as per -U{XEP-0060} that interacts with -a given storage facility. It also provides an adapter from the XMPP -publish-subscribe protocol. -""" - -import copy -import uuid - -from zope.interface import implements - -from twisted.application import service -from twisted.python import components, log -from twisted.internet import defer, reactor -from twisted.words.protocols.jabber.error import StanzaError -# from twisted.words.protocols.jabber.jid import JID, InvalidFormat -from twisted.words.xish import domish, utility - -from wokkel import disco -from wokkel import data_form -from wokkel import rsm -from wokkel import iwokkel -from wokkel import pubsub -from wokkel.subprotocols import XMPPHandler - -from sat_pubsub import error -from sat_pubsub import iidavoll -from sat_pubsub import const -from sat_pubsub import container - - -def _getAffiliation(node, entity): - d = node.getAffiliation(entity) - d.addCallback(lambda affiliation: (node, affiliation)) - return d - - -def elementCopy(element): - """Make a copy of a domish.Element - - The copy will have its own children list, so other elements - can be added as direct children without modifying orignal one. - Children are not deeply copied, so if an element is added to a child or grandchild, - it will also affect original element. - @param element(domish.Element): Element to clone - """ - new_elt = domish.Element( - (element.uri, element.name), - defaultUri = element.defaultUri, - attribs = element.attributes, - localPrefixes = element.localPrefixes) - new_elt.parent = element.parent - new_elt.children = element.children[:] - return new_elt - - -def itemDataCopy(item_data): - """Make a copy of an item_data - - deep copy every element of the tuple but item - do an elementCopy of item_data.item - @param item_data(ItemData): item data to copy - @return (ItemData): copied data - """ - return container.ItemData(*[elementCopy(item_data.item)] - + [copy.deepcopy(d) for d in item_data[1:]]) - - -class BackendService(service.Service, utility.EventDispatcher): - """ - Generic publish-subscribe backend service. - - @cvar nodeOptions: Node configuration form as a mapping from the field - name to a dictionary that holds the field's type, label - and possible options to choose from. - @type nodeOptions: C{dict}. - @cvar defaultConfig: The default node configuration. - """ - - implements(iidavoll.IBackendService) - - nodeOptions = { - const.OPT_PERSIST_ITEMS: - {"type": "boolean", - "label": "Persist items to storage"}, - const.OPT_DELIVER_PAYLOADS: - {"type": "boolean", - "label": "Deliver payloads with event notifications"}, - const.OPT_SEND_LAST_PUBLISHED_ITEM: - {"type": "list-single", - "label": "When to send the last published item", - "options": { - "never": "Never", - "on_sub": "When a new subscription is processed"} - }, - const.OPT_ACCESS_MODEL: - {"type": "list-single", - "label": "Who can subscribe to this node", - "options": { - const.VAL_AMODEL_OPEN: "Public node", - const.VAL_AMODEL_PRESENCE: "Node restricted to entites subscribed to owner presence", - const.VAL_AMODEL_PUBLISHER_ROSTER: "Node restricted to some groups of publisher's roster", - const.VAL_AMODEL_WHITELIST: "Node restricted to some jids", - } - }, - const.OPT_ROSTER_GROUPS_ALLOWED: - {"type": "list-multi", - "label": "Groups of the roster allowed to access the node", - }, - const.OPT_PUBLISH_MODEL: - {"type": "list-single", - "label": "Who can publish to this node", - "options": { - const.VAL_PMODEL_OPEN: "Everybody can publish", - const.VAL_PMODEL_PUBLISHERS: "Only owner and publishers can publish", - const.VAL_PMODEL_SUBSCRIBERS: "Everybody which subscribed to the node", - } - }, - const.OPT_SERIAL_IDS: - {"type": "boolean", - "label": "Use serial ids"}, - const.OPT_CONSISTENT_PUBLISHER: - {"type": "boolean", - "label": "Keep publisher on update"}, - } - - subscriptionOptions = { - "pubsub#subscription_type": - {"type": "list-single", - "options": { - "items": "Receive notification of new items only", - "nodes": "Receive notification of new nodes only"} - }, - "pubsub#subscription_depth": - {"type": "list-single", - "options": { - "1": "Receive notification from direct child nodes only", - "all": "Receive notification from all descendent nodes"} - }, - } - - def __init__(self, storage, config): - utility.EventDispatcher.__init__(self) - self.storage = storage - self._callbackList = [] - self.config = config - self.admins = config[u'admins_jids_list'] - - def isAdmin(self, entity_jid): - """Return True if an entity is an administrator""" - return entity_jid.userhostJID() in self.admins - - def supportsPublishOptions(self): - return True - def supportsPublisherAffiliation(self): - return True - - def supportsGroupBlog(self): - return True - - def supportsOutcastAffiliation(self): - return True - - def supportsPersistentItems(self): - return True - - def supportsPublishModel(self): - return True - - def getNodeType(self, nodeIdentifier, pep, recipient=None): - # FIXME: manage pep and recipient - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(lambda node: node.getType()) - return d - - def _getNodesIds(self, subscribed, pep, recipient): - # TODO: filter whitelist nodes - # TODO: handle publisher-roster (should probably be renamed to owner-roster for nodes) - if not subscribed: - allowed_accesses = {'open', 'whitelist'} - else: - allowed_accesses = {'open', 'presence', 'whitelist'} - return self.storage.getNodeIds(pep, recipient, allowed_accesses) - - def getNodes(self, requestor, pep, recipient): - if pep: - d = self.privilege.isSubscribedFrom(requestor, recipient) - d.addCallback(self._getNodesIds, pep, recipient) - return d - return self.storage.getNodeIds(pep, recipient) - - def getNodeMetaData(self, nodeIdentifier, pep, recipient=None): - # FIXME: manage pep and recipient - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(lambda node: node.getMetaData()) - d.addCallback(self._makeMetaData) - return d - - def _makeMetaData(self, metaData): - options = [] - for key, value in metaData.iteritems(): - if key in self.nodeOptions: - option = {"var": key} - option.update(self.nodeOptions[key]) - option["value"] = value - options.append(option) - - return options - - def _checkAuth(self, node, requestor): - """ Check authorisation of publishing in node for requestor """ - - def check(affiliation): - d = defer.succeed((affiliation, node)) - configuration = node.getConfiguration() - publish_model = configuration[const.OPT_PUBLISH_MODEL] - if publish_model == const.VAL_PMODEL_PUBLISHERS: - if affiliation not in ['owner', 'publisher']: - raise error.Forbidden() - elif publish_model == const.VAL_PMODEL_SUBSCRIBERS: - if affiliation not in ['owner', 'publisher']: - # we are in subscribers publish model, we must check that - # the requestor is a subscriber to allow him to publish - - def checkSubscription(subscribed): - if not subscribed: - raise error.Forbidden() - return (affiliation, node) - - d.addCallback(lambda ignore: node.isSubscribed(requestor)) - d.addCallback(checkSubscription) - elif publish_model != const.VAL_PMODEL_OPEN: - raise ValueError('Unexpected value') # publish_model must be publishers (default), subscribers or open. - - return d - - d = node.getAffiliation(requestor) - d.addCallback(check) - return d - - def parseItemConfig(self, item): - """Get and remove item configuration information - - @param item (domish.Element): item to parse - @return (tuple[unicode, dict)): (access_model, item_config) - """ - item_config = None - access_model = const.VAL_AMODEL_DEFAULT - for idx, elt in enumerate(item.elements()): - if elt.uri != data_form.NS_X_DATA or elt.name != 'x': - continue - form = data_form.Form.fromElement(elt) - if form.formNamespace == const.NS_ITEM_CONFIG: - item_config = form - del item.children[idx] #we need to remove the config from item - break - - if item_config: - access_model = item_config.get(const.OPT_ACCESS_MODEL, const.VAL_AMODEL_DEFAULT) - return (access_model, item_config) - - def parseCategories(self, item_elt): - """Check if item contain an atom entry, and parse categories if possible - - @param item_elt (domish.Element): item to parse - @return (list): list of found categories - """ - categories = [] - try: - entry_elt = item_elt.elements(const.NS_ATOM, "entry").next() - except StopIteration: - return categories - - for category_elt in entry_elt.elements(const.NS_ATOM, 'category'): - category = category_elt.getAttribute('term') - if category: - categories.append(category) - - return categories - - def enforceSchema(self, item_elt, schema, affiliation): - """modifify item according to element, or refuse publishing - - @param item_elt(domish.Element): item to check/modify - @param schema(domish.Eement): schema to enfore - @param affiliation(unicode): affiliation of the publisher - """ - try: - x_elt = next(item_elt.elements(data_form.NS_X_DATA, 'x')) - item_form = data_form.Form.fromElement(x_elt) - except (StopIteration, data_form.Error): - raise pubsub.BadRequest(text="node has a schema but item has no form") - else: - item_elt.children.remove(x_elt) - - schema_form = data_form.Form.fromElement(schema) - - # we enforce restrictions - for field_elt in schema.elements(data_form.NS_X_DATA, 'field'): - var = field_elt['var'] - for restrict_elt in field_elt.elements(const.NS_SCHEMA_RESTRICT, 'restrict'): - write_restriction = restrict_elt.attributes.get('write') - if write_restriction is not None: - if write_restriction == 'owner': - if affiliation != 'owner': - # write is not allowed on this field, we use default value - # we can safely use Field from schema_form because - # we have created this instance only for this method - try: - item_form.removeField(item_form.fields[var]) - except KeyError: - pass - item_form.addField(schema_form.fields[var]) - else: - raise StanzaError('feature-not-implemented', text='unknown write restriction {}'.format(write_restriction)) - - # we now remove every field which is not in data schema - to_remove = set() - for item_var, item_field in item_form.fields.iteritems(): - if item_var not in schema_form.fields: - to_remove.add(item_field) - - for field in to_remove: - item_form.removeField(field) - item_elt.addChild(item_form.toElement()) - - def _checkOverwrite(self, node, itemIdentifiers, publisher): - """Check that publisher can overwrite items - - current publisher must correspond to each item publisher - """ - def doCheck(item_pub_map): - for item_publisher in item_pub_map.itervalues(): - if item_publisher.userhost() != publisher.userhost(): - raise error.ItemForbidden() - - d = node.getItemsPublishers(itemIdentifiers) - d.addCallback(doCheck) - return d - - def publish(self, nodeIdentifier, items, requestor, pep, recipient): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(self._checkAuth, requestor) - #FIXME: owner and publisher are not necessarly the same. So far we use only owner to get roster. - #FIXME: in addition, there can be several owners: that is not managed yet - d.addCallback(self._doPublish, items, requestor, pep, recipient) - return d - - @defer.inlineCallbacks - def _doPublish(self, result, items, requestor, pep, recipient): - affiliation, node = result - if node.nodeType == 'collection': - raise error.NoPublishing() - - configuration = node.getConfiguration() - persistItems = configuration[const.OPT_PERSIST_ITEMS] - deliverPayloads = configuration[const.OPT_DELIVER_PAYLOADS] - - if items and not persistItems and not deliverPayloads: - raise error.ItemForbidden() - elif not items and (persistItems or deliverPayloads): - raise error.ItemRequired() - - items_data = [] - check_overwrite = False - ret_payload = None # payload returned, None or domish.Element - for item in items: - # we enforce publisher (cf XEP-0060 §7.1.2.3) - item['publisher'] = requestor.full() - if persistItems or deliverPayloads: - item.uri = None - item.defaultUri = None - if not item.getAttribute("id"): - item["id"] = yield node.getNextId() - new_item = True - if ret_payload is None: - ret_pubsub_elt = domish.Element((pubsub.NS_PUBSUB, u'pubsub')) - ret_publish_elt = ret_pubsub_elt.addElement(u'publish') - ret_publish_elt[u'node'] = node.nodeIdentifier - ret_payload = ret_pubsub_elt - ret_publish_elt = ret_payload.publish - ret_item_elt = ret_publish_elt.addElement(u'item') - ret_item_elt["id"] = item[u"id"] - else: - check_overwrite = True - new_item = False - access_model, item_config = self.parseItemConfig(item) - categories = self.parseCategories(item) - schema = node.getSchema() - if schema is not None: - self.enforceSchema(item, schema, affiliation) - items_data.append(container.ItemData(item, access_model, item_config, categories, new=new_item)) - - if persistItems: - - if check_overwrite: - itemIdentifiers = [item['id'] for item in items - if item.getAttribute('id')] - - if affiliation == 'owner' or self.isAdmin(requestor): - if configuration[const.OPT_CONSISTENT_PUBLISHER]: - pub_map = yield node.getItemsPublishers(itemIdentifiers) - publishers = set(pub_map.values()) - if len(publishers) != 1: - # TODO: handle multiple items publishing (from several - # publishers) - raise error.NoPublishing( - u"consistent_publisher is currently only possible when " - u"publishing items from a single publisher. Try to " - u"publish one item at a time") - # we replace requestor and new payload's publisher by original - # item publisher to keep publisher consistent - requestor = publishers.pop() - for item in items: - item['publisher'] = requestor.full() - else: - # we don't want a publisher to overwrite the item - # of an other publisher - yield self._checkOverwrite(node, itemIdentifiers, requestor) - - # TODO: check conflict and recalculate max id if serial_ids is set - yield node.storeItems(items_data, requestor) - - yield self._doNotify(node, items_data, deliverPayloads, pep, recipient) - defer.returnValue(ret_payload) - - def _doNotify(self, node, items_data, deliverPayloads, pep, recipient): - if items_data and not deliverPayloads: - for item_data in items_data: - item_data.item.children = [] - self.dispatch({'items_data': items_data, 'node': node, 'pep': pep, 'recipient': recipient}, - '//event/pubsub/notify') - - def getNotifications(self, node, items_data): - """Build a list of subscriber to the node - - subscribers will be associated with subscribed items, - and subscription type. - """ - - def toNotifications(subscriptions, items_data): - subsBySubscriber = {} - for subscription in subscriptions: - if subscription.options.get('pubsub#subscription_type', - 'items') == 'items': - subs = subsBySubscriber.setdefault(subscription.subscriber, - set()) - subs.add(subscription) - - notifications = [(subscriber, subscriptions_, items_data) - for subscriber, subscriptions_ - in subsBySubscriber.iteritems()] - - return notifications - - def rootNotFound(failure): - failure.trap(error.NodeNotFound) - return [] - - d1 = node.getSubscriptions('subscribed') - # FIXME: must add root node subscriptions ? - # d2 = self.storage.getNode('', False) # FIXME: to check - # d2.addCallback(lambda node: node.getSubscriptions('subscribed')) - # d2.addErrback(rootNotFound) - # d = defer.gatherResults([d1, d2]) - # d.addCallback(lambda result: result[0] + result[1]) - d1.addCallback(toNotifications, items_data) - return d1 - - def registerPublishNotifier(self, observerfn, *args, **kwargs): - self.addObserver('//event/pubsub/notify', observerfn, *args, **kwargs) - - def registerRetractNotifier(self, observerfn, *args, **kwargs): - self.addObserver('//event/pubsub/retract', observerfn, *args, **kwargs) - - def subscribe(self, nodeIdentifier, subscriber, requestor, pep, recipient): - subscriberEntity = subscriber.userhostJID() - if subscriberEntity != requestor.userhostJID(): - return defer.fail(error.Forbidden()) - - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, subscriberEntity) - d.addCallback(self._doSubscribe, subscriber, pep, recipient) - return d - - def _doSubscribe(self, result, subscriber, pep, recipient): - node, affiliation = result - - if affiliation == 'outcast': - raise error.Forbidden() - - access_model = node.getAccessModel() - - if access_model == const.VAL_AMODEL_OPEN: - d = defer.succeed(None) - elif access_model == const.VAL_AMODEL_PRESENCE: - d = self.checkPresenceSubscription(node, subscriber) - elif access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: - d = self.checkRosterGroups(node, subscriber) - elif access_model == const.VAL_AMODEL_WHITELIST: - d = self.checkNodeAffiliations(node, subscriber) - else: - raise NotImplementedError - - def trapExists(failure): - failure.trap(error.SubscriptionExists) - return False - - def cb(sendLast): - d = node.getSubscription(subscriber) - if sendLast: - d.addCallback(self._sendLastPublished, node, pep, recipient) - return d - - d.addCallback(lambda _: node.addSubscription(subscriber, 'subscribed', {})) - d.addCallbacks(lambda _: True, trapExists) - d.addCallback(cb) - - return d - - def _sendLastPublished(self, subscription, node, pep, recipient): - - def notifyItem(items_data): - if items_data: - reactor.callLater(0, self.dispatch, - {'items_data': items_data, - 'node': node, - 'pep': pep, - 'recipient': recipient, - 'subscription': subscription, - }, - '//event/pubsub/notify') - - config = node.getConfiguration() - sendLastPublished = config.get('pubsub#send_last_published_item', - 'never') - if sendLastPublished == 'on_sub' and node.nodeType == 'leaf': - entity = subscription.subscriber.userhostJID() - d = self.getItemsData(node.nodeIdentifier, entity, recipient, maxItems=1, ext_data={'pep': pep}) - d.addCallback(notifyItem) - d.addErrback(log.err) - - return subscription - - def unsubscribe(self, nodeIdentifier, subscriber, requestor, pep, recipient): - if subscriber.userhostJID() != requestor.userhostJID(): - return defer.fail(error.Forbidden()) - - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(lambda node: node.removeSubscription(subscriber)) - return d - - def getSubscriptions(self, requestor, nodeIdentifier, pep, recipient): - """retrieve subscriptions of an entity - - @param requestor(jid.JID): entity who want to check subscriptions - @param nodeIdentifier(unicode, None): identifier of the node - node to get all subscriptions of a service - @param pep(bool): True if it's a PEP request - @param recipient(jid.JID, None): recipient of the PEP request - """ - return self.storage.getSubscriptions(requestor, nodeIdentifier, pep, recipient) - - def supportsAutoCreate(self): - return True - - def supportsCreatorCheck(self): - return True - - def supportsInstantNodes(self): - return True - - def createNode(self, nodeIdentifier, requestor, options = None, pep=False, recipient=None): - if not nodeIdentifier: - nodeIdentifier = 'generic/%s' % uuid.uuid4() - - if not options: - options = {} - - # if self.supportsCreatorCheck(): - # groupblog = nodeIdentifier.startswith(const.NS_GROUPBLOG_PREFIX) - # try: - # nodeIdentifierJID = JID(nodeIdentifier[len(const.NS_GROUPBLOG_PREFIX):] if groupblog else nodeIdentifier) - # except InvalidFormat: - # is_user_jid = False - # else: - # is_user_jid = bool(nodeIdentifierJID.user) - - # if is_user_jid and nodeIdentifierJID.userhostJID() != requestor.userhostJID(): - # #we have an user jid node, but not created by the owner of this jid - # print "Wrong creator" - # raise error.Forbidden() - - nodeType = 'leaf' - config = self.storage.getDefaultConfiguration(nodeType) - config['pubsub#node_type'] = nodeType - config.update(options) - - # TODO: handle schema on creation - d = self.storage.createNode(nodeIdentifier, requestor, config, None, pep, recipient) - d.addCallback(lambda _: nodeIdentifier) - return d - - def getDefaultConfiguration(self, nodeType): - d = defer.succeed(self.storage.getDefaultConfiguration(nodeType)) - return d - - def getNodeConfiguration(self, nodeIdentifier, pep, recipient): - if not nodeIdentifier: - return defer.fail(error.NoRootNode()) - - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(lambda node: node.getConfiguration()) - - return d - - def setNodeConfiguration(self, nodeIdentifier, options, requestor, pep, recipient): - if not nodeIdentifier: - return defer.fail(error.NoRootNode()) - - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doSetNodeConfiguration, requestor, options) - return d - - def _doSetNodeConfiguration(self, result, requestor, options): - node, affiliation = result - - if affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - - return node.setConfiguration(options) - - def getNodeSchema(self, nodeIdentifier, pep, recipient): - if not nodeIdentifier: - return defer.fail(error.NoRootNode()) - - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(lambda node: node.getSchema()) - - return d - - def setNodeSchema(self, nodeIdentifier, schema, requestor, pep, recipient): - """set or remove Schema of a node - - @param nodeIdentifier(unicode): identifier of the pubusb node - @param schema(domish.Element, None): schema to set - None to remove schema - @param requestor(jid.JID): entity doing the request - @param pep(bool): True if it's a PEP request - @param recipient(jid.JID, None): recipient of the PEP request - """ - if not nodeIdentifier: - return defer.fail(error.NoRootNode()) - - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doSetNodeSchema, requestor, schema) - return d - - def _doSetNodeSchema(self, result, requestor, schema): - node, affiliation = result - - if affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - - return node.setSchema(schema) - - def getAffiliations(self, entity, nodeIdentifier, pep, recipient): - return self.storage.getAffiliations(entity, nodeIdentifier, pep, recipient) - - def getAffiliationsOwner(self, nodeIdentifier, requestor, pep, recipient): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doGetAffiliationsOwner, requestor) - return d - - def _doGetAffiliationsOwner(self, result, requestor): - node, affiliation = result - - if affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - return node.getAffiliations() - - def setAffiliationsOwner(self, nodeIdentifier, requestor, affiliations, pep, recipient): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doSetAffiliationsOwner, requestor, affiliations) - return d - - def _doSetAffiliationsOwner(self, result, requestor, affiliations): - # Check that requestor is allowed to set affiliations, and delete entities - # with "none" affiliation - - # TODO: return error with failed affiliations in case of failure - node, requestor_affiliation = result - - if requestor_affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - - # we don't allow requestor to change its own affiliation - requestor_bare = requestor.userhostJID() - if requestor_bare in affiliations and affiliations[requestor_bare] != 'owner': - # FIXME: it may be interesting to allow the owner to ask for ownership removal - # if at least one other entity is owner for this node - raise error.Forbidden("You can't change your own affiliation") - - to_delete = [jid_ for jid_, affiliation in affiliations.iteritems() if affiliation == 'none'] - for jid_ in to_delete: - del affiliations[jid_] - - if to_delete: - d = node.deleteAffiliations(to_delete) - if affiliations: - d.addCallback(lambda dummy: node.setAffiliations(affiliations)) - else: - d = node.setAffiliations(affiliations) - - return d - - def getSubscriptionsOwner(self, nodeIdentifier, requestor, pep, recipient): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doGetSubscriptionsOwner, requestor) - return d - - def _doGetSubscriptionsOwner(self, result, requestor): - node, affiliation = result - - if affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - return node.getSubscriptions() - - def setSubscriptionsOwner(self, nodeIdentifier, requestor, subscriptions, pep, recipient): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doSetSubscriptionsOwner, requestor, subscriptions) - return d - - def unwrapFirstError(self, failure): - failure.trap(defer.FirstError) - return failure.value.subFailure - - def _doSetSubscriptionsOwner(self, result, requestor, subscriptions): - # Check that requestor is allowed to set subscriptions, and delete entities - # with "none" subscription - - # TODO: return error with failed subscriptions in case of failure - node, requestor_affiliation = result - - if requestor_affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - - d_list = [] - - for subscription in subscriptions.copy(): - if subscription.state == 'none': - subscriptions.remove(subscription) - d_list.append(node.removeSubscription(subscription.subscriber)) - - if subscriptions: - d_list.append(node.setSubscriptions(subscriptions)) - - d = defer.gatherResults(d_list, consumeErrors=True) - d.addCallback(lambda _: None) - d.addErrback(self.unwrapFirstError) - return d - - def filterItemsWithSchema(self, items_data, schema, owner): - """check schema restriction and remove fields/items if they don't comply - - @param items_data(list[ItemData]): items to filter - items in this list will be modified - @param schema(domish.Element): node schema - @param owner(bool): True is requestor is a owner of the node - """ - fields_to_remove = set() - for field_elt in schema.elements(data_form.NS_X_DATA, 'field'): - for restrict_elt in field_elt.elements(const.NS_SCHEMA_RESTRICT, 'restrict'): - read_restriction = restrict_elt.attributes.get('read') - if read_restriction is not None: - if read_restriction == 'owner': - if not owner: - fields_to_remove.add(field_elt['var']) - else: - raise StanzaError('feature-not-implemented', text='unknown read restriction {}'.format(read_restriction)) - items_to_remove = [] - for idx, item_data in enumerate(items_data): - item_elt = item_data.item - try: - x_elt = next(item_elt.elements(data_form.NS_X_DATA, 'x')) - except StopIteration: - log.msg("WARNING, item {id} has a schema but no form, ignoring it") - items_to_remove.append(item_data) - continue - form = data_form.Form.fromElement(x_elt) - # we remove fields which are not visible for this user - for field in fields_to_remove: - try: - form.removeField(form.fields[field]) - except KeyError: - continue - item_elt.children.remove(x_elt) - item_elt.addChild(form.toElement()) - - for item_data in items_to_remove: - items_data.remove(item_data) - - def checkPresenceSubscription(self, node, requestor): - """check if requestor has presence subscription from node owner - - @param node(Node): node to check - @param requestor(jid.JID): entity who want to access node - """ - def gotRoster(roster): - if roster is None: - raise error.Forbidden() - - if requestor not in roster: - raise error.Forbidden() - - if not roster[requestor].subscriptionFrom: - raise error.Forbidden() - - d = self.getOwnerRoster(node) - d.addCallback(gotRoster) - return d - - @defer.inlineCallbacks - def checkRosterGroups(self, node, requestor): - """check if requestor is in allowed groups of a node - - @param node(Node): node to check - @param requestor(jid.JID): entity who want to access node - """ - roster = yield self.getOwnerRoster(node) - - if roster is None: - raise error.Forbidden() - - if requestor not in roster: - raise error.Forbidden() - - authorized_groups = yield node.getAuthorizedGroups() - - if not roster[requestor].groups.intersection(authorized_groups): - # requestor is in roster but not in one of the allowed groups - raise error.Forbidden() - - def checkNodeAffiliations(self, node, requestor): - """check if requestor is in white list of a node - - @param node(Node): node to check - @param requestor(jid.JID): entity who want to access node - """ - def gotAffiliations(affiliations): - try: - affiliation = affiliations[requestor.userhostJID()] - except KeyError: - raise error.Forbidden() - else: - if affiliation not in ('owner', 'publisher', 'member'): - raise error.Forbidden() - - d = node.getAffiliations() - d.addCallback(gotAffiliations) - return d - - @defer.inlineCallbacks - def checkNodeAccess(self, node, requestor): - """check if a requestor can access data of a node - - @param node(Node): node to check - @param requestor(jid.JID): entity who want to access node - @return (tuple): permissions data with: - - owner(bool): True if requestor is owner of the node - - roster(None, ): roster of the requestor - None if not needed/available - - access_model(str): access model of the node - @raise error.Forbidden: access is not granted - @raise error.NotLeafNodeError: this node is not a leaf - """ - node, affiliation = yield _getAffiliation(node, requestor) - - if not iidavoll.ILeafNode.providedBy(node): - raise error.NotLeafNodeError() - - if affiliation == 'outcast': - raise error.Forbidden() - - # node access check - owner = affiliation == 'owner' - access_model = node.getAccessModel() - roster = None - - if access_model == const.VAL_AMODEL_OPEN or owner: - pass - elif access_model == const.VAL_AMODEL_PRESENCE: - yield self.checkPresenceSubscription(node, requestor) - elif access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: - # FIXME: for node, access should be renamed owner-roster, not publisher - yield self.checkRosterGroups(node, requestor) - elif access_model == const.VAL_AMODEL_WHITELIST: - yield self.checkNodeAffiliations(node, requestor) - else: - raise Exception(u"Unknown access_model") - - defer.returnValue((affiliation, owner, roster, access_model)) - - @defer.inlineCallbacks - def getItemsIds(self, nodeIdentifier, requestor, authorized_groups, unrestricted, maxItems=None, ext_data=None, pep=False, recipient=None): - # FIXME: items access model are not checked - # TODO: check items access model - node = yield self.storage.getNode(nodeIdentifier, pep, recipient) - affiliation, owner, roster, access_model = yield self.checkNodeAccess(node, requestor) - ids = yield node.getItemsIds(authorized_groups, - unrestricted, - maxItems, - ext_data) - defer.returnValue(ids) - - def getItems(self, nodeIdentifier, requestor, recipient, maxItems=None, - itemIdentifiers=None, ext_data=None): - d = self.getItemsData(nodeIdentifier, requestor, recipient, maxItems, itemIdentifiers, ext_data) - d.addCallback(lambda items_data: [item_data.item for item_data in items_data]) - return d - - @defer.inlineCallbacks - def getOwnerRoster(self, node, owners=None): - # FIXME: roster of publisher, not owner, must be used - if owners is None: - owners = yield node.getOwners() - - if len(owners) != 1: - log.msg('publisher-roster access is not allowed with more than 1 owner') - return - - owner_jid = owners[0] - - try: - roster = yield self.privilege.getRoster(owner_jid) - except Exception as e: - log.msg("Error while getting roster of {owner_jid}: {msg}".format( - owner_jid = owner_jid.full(), - msg = e)) - return - defer.returnValue(roster) - - @defer.inlineCallbacks - def getItemsData(self, nodeIdentifier, requestor, recipient, maxItems=None, - itemIdentifiers=None, ext_data=None): - """like getItems but return the whole ItemData""" - if maxItems == 0: - log.msg("WARNING: maxItems=0 on items retrieval") - defer.returnValue([]) - - if ext_data is None: - ext_data = {} - node = yield self.storage.getNode(nodeIdentifier, ext_data.get('pep', False), recipient) - try: - affiliation, owner, roster, access_model = yield self.checkNodeAccess(node, requestor) - except error.NotLeafNodeError: - defer.returnValue([]) - - # at this point node access is checked - - if owner: - # requestor_groups is only used in restricted access - requestor_groups = None - else: - if roster is None: - # FIXME: publisher roster should be used, not owner - roster = yield self.getOwnerRoster(node) - if roster is None: - roster = {} - roster_item = roster.get(requestor.userhostJID()) - requestor_groups = tuple(roster_item.groups) if roster_item else tuple() - - if itemIdentifiers: - items_data = yield node.getItemsById(requestor_groups, owner, itemIdentifiers) - else: - items_data = yield node.getItems(requestor_groups, owner, maxItems, ext_data) - - if owner: - # Add item config data form to items with roster access model - for item_data in items_data: - if item_data.access_model == const.VAL_AMODEL_OPEN: - pass - elif item_data.access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: - form = data_form.Form('submit', formNamespace=const.NS_ITEM_CONFIG) - access = data_form.Field(None, const.OPT_ACCESS_MODEL, value=const.VAL_AMODEL_PUBLISHER_ROSTER) - allowed = data_form.Field(None, const.OPT_ROSTER_GROUPS_ALLOWED, values=item_data.config[const.OPT_ROSTER_GROUPS_ALLOWED]) - form.addField(access) - form.addField(allowed) - item_data.item.addChild(form.toElement()) - elif access_model == const.VAL_AMODEL_WHITELIST: - #FIXME - raise NotImplementedError - else: - raise error.BadAccessTypeError(access_model) - - schema = node.getSchema() - if schema is not None: - self.filterItemsWithSchema(items_data, schema, owner) - - yield self._items_rsm(items_data, node, requestor_groups, owner, itemIdentifiers, ext_data) - defer.returnValue(items_data) - - def _setCount(self, value, response): - response.count = value - - def _setIndex(self, value, response, adjust): - """Set index in RSM response - - @param value(int): value of the reference index (i.e. before or after item) - @param response(RSMResponse): response instance to fill - @param adjust(int): adjustement term (i.e. difference between reference index and first item of the result) - """ - response.index = value + adjust - - def _items_rsm(self, items_data, node, authorized_groups, owner, - itemIdentifiers, ext_data): - # FIXME: move this to a separate module - # TODO: Index can be optimized by keeping a cache of the last RSM request - # An other optimisation would be to look for index first and use it as offset - try: - rsm_request = ext_data['rsm'] - except KeyError: - # No RSM in this request, nothing to do - return items_data - - if itemIdentifiers: - log.msg("WARNING, itemIdentifiers used with RSM, ignoring the RSM part") - return items_data - - response = rsm.RSMResponse() - - d_count = node.getItemsCount(authorized_groups, owner, ext_data) - d_count.addCallback(self._setCount, response) - d_list = [d_count] - - if items_data: - response.first = items_data[0].item['id'] - response.last = items_data[-1].item['id'] - - # index handling - if rsm_request.index is not None: - response.index = rsm_request.index - elif rsm_request.before: - # The last page case (before == '') is managed in render method - d_index = node.getItemsIndex(rsm_request.before, authorized_groups, owner, ext_data) - d_index.addCallback(self._setIndex, response, -len(items_data)) - d_list.append(d_index) - elif rsm_request.after is not None: - d_index = node.getItemsIndex(rsm_request.after, authorized_groups, owner, ext_data) - d_index.addCallback(self._setIndex, response, 1) - d_list.append(d_index) - else: - # the first page was requested - response.index = 0 - - def render(result): - if rsm_request.before == '': - # the last page was requested - response.index = response.count - len(items_data) - items_data.append(container.ItemData(response.toElement())) - return items_data - - return defer.DeferredList(d_list).addCallback(render) - - def retractItem(self, nodeIdentifier, itemIdentifiers, requestor, notify, pep, recipient): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doRetract, itemIdentifiers, requestor, notify, pep, recipient) - return d - - def _doRetract(self, result, itemIdentifiers, requestor, notify, pep, recipient): - node, affiliation = result - persistItems = node.getConfiguration()[const.OPT_PERSIST_ITEMS] - - if not persistItems: - raise error.NodeNotPersistent() - - # we need to get the items before removing them, for the notifications - - def removeItems(items_data): - """Remove the items and keep only actually removed ones in items_data""" - d = node.removeItems(itemIdentifiers) - d.addCallback(lambda removed: [item_data for item_data in items_data if item_data.item["id"] in removed]) - return d - - def checkPublishers(publishers_map): - """Called when requestor is neither owner neither publisher of the Node - - We check that requestor is publisher of all the items he wants to retract - and raise error.Forbidden if it is not the case - """ - # TODO: the behaviour should be configurable (per node ?) - if (any((requestor.userhostJID() != publisher.userhostJID() - for publisher in publishers_map.itervalues())) - and not self.isAdmin(requestor) - ): - raise error.Forbidden() - - if affiliation in ['owner', 'publisher']: - # the requestor is owner or publisher of the node - # he can retract what he wants - d = defer.succeed(None) - else: - # the requestor doesn't have right to retract on the whole node - # we check if he is a publisher for all items he wants to retract - # and forbid the retraction else. - d = node.getItemsPublishers(itemIdentifiers) - d.addCallback(checkPublishers) - d.addCallback(lambda dummy: node.getItemsById(None, True, itemIdentifiers)) - d.addCallback(removeItems) - - if notify: - d.addCallback(self._doNotifyRetraction, node, pep, recipient) - return d - - def _doNotifyRetraction(self, items_data, node, pep, recipient): - self.dispatch({'items_data': items_data, - 'node': node, - 'pep': pep, - 'recipient': recipient}, - '//event/pubsub/retract') - - def purgeNode(self, nodeIdentifier, requestor, pep, recipient): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doPurge, requestor) - return d - - def _doPurge(self, result, requestor): - node, affiliation = result - persistItems = node.getConfiguration()[const.OPT_PERSIST_ITEMS] - - if affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - - if not persistItems: - raise error.NodeNotPersistent() - - d = node.purge() - d.addCallback(self._doNotifyPurge, node.nodeIdentifier) - return d - - def _doNotifyPurge(self, result, nodeIdentifier): - self.dispatch(nodeIdentifier, '//event/pubsub/purge') - - def registerPreDelete(self, preDeleteFn): - self._callbackList.append(preDeleteFn) - - def getSubscribers(self, nodeIdentifier, pep, recipient): - def cb(subscriptions): - return [subscription.subscriber for subscription in subscriptions] - - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(lambda node: node.getSubscriptions('subscribed')) - d.addCallback(cb) - return d - - def deleteNode(self, nodeIdentifier, requestor, pep, recipient, redirectURI=None): - d = self.storage.getNode(nodeIdentifier, pep, recipient) - d.addCallback(_getAffiliation, requestor) - d.addCallback(self._doPreDelete, requestor, redirectURI, pep, recipient) - return d - - def _doPreDelete(self, result, requestor, redirectURI, pep, recipient): - node, affiliation = result - - if affiliation != 'owner' and not self.isAdmin(requestor): - raise error.Forbidden() - - data = {'node': node, - 'redirectURI': redirectURI} - - d = defer.DeferredList([cb(data, pep, recipient) - for cb in self._callbackList], - consumeErrors=1) - d.addCallback(self._doDelete, node.nodeDbId) - - def _doDelete(self, result, nodeDbId): - dl = [] - for succeeded, r in result: - if succeeded and r: - dl.extend(r) - - d = self.storage.deleteNodeByDbId(nodeDbId) - d.addCallback(self._doNotifyDelete, dl) - - return d - - def _doNotifyDelete(self, result, dl): - for d in dl: - d.callback(None) - - -class PubSubResourceFromBackend(pubsub.PubSubResource): - """ - Adapts a backend to an xmpp publish-subscribe service. - """ - - features = [ - "config-node", - "create-nodes", - "delete-any", - "delete-nodes", - "item-ids", - "meta-data", - "publish", - "purge-nodes", - "retract-items", - "retrieve-affiliations", - "retrieve-default", - "retrieve-items", - "retrieve-subscriptions", - "subscribe", - ] - - discoIdentity = disco.DiscoIdentity('pubsub', - 'service', - u'Salut à Toi pubsub service') - - pubsubService = None - - _errorMap = { - error.NodeNotFound: ('item-not-found', None, None), - error.NodeExists: ('conflict', None, None), - error.Forbidden: ('forbidden', None, None), - error.NotAuthorized: ('not-authorized', None, None), - error.ItemNotFound: ('item-not-found', None, None), - error.ItemForbidden: ('bad-request', 'item-forbidden', None), - error.ItemRequired: ('bad-request', 'item-required', None), - error.NoInstantNodes: ('not-acceptable', - 'unsupported', - 'instant-nodes'), - error.NotSubscribed: ('unexpected-request', 'not-subscribed', None), - error.InvalidConfigurationOption: ('not-acceptable', None, None), - error.InvalidConfigurationValue: ('not-acceptable', None, None), - error.NodeNotPersistent: ('feature-not-implemented', - 'unsupported', - 'persistent-node'), - error.NoRootNode: ('bad-request', None, None), - error.NoCollections: ('feature-not-implemented', - 'unsupported', - 'collections'), - error.NoPublishing: ('feature-not-implemented', - 'unsupported', - 'publish'), - } - - def __init__(self, backend): - pubsub.PubSubResource.__init__(self) - - self.backend = backend - self.hideNodes = False - - self.backend.registerPublishNotifier(self._notifyPublish) - self.backend.registerRetractNotifier(self._notifyRetract) - self.backend.registerPreDelete(self._preDelete) - - # FIXME: to be removed, it's not useful anymore as PEP is now used - # if self.backend.supportsCreatorCheck(): - # self.features.append("creator-jid-check") #SàT custom feature: Check that a node (which correspond to - # a jid in this server) is created by the right jid - - if self.backend.supportsAutoCreate(): - self.features.append("auto-create") - - if self.backend.supportsPublishOptions(): - self.features.append("publish-options") - - if self.backend.supportsInstantNodes(): - self.features.append("instant-nodes") - - if self.backend.supportsOutcastAffiliation(): - self.features.append("outcast-affiliation") - - if self.backend.supportsPersistentItems(): - self.features.append("persistent-items") - - if self.backend.supportsPublisherAffiliation(): - self.features.append("publisher-affiliation") - - if self.backend.supportsGroupBlog(): - self.features.append("groupblog") - - - # if self.backend.supportsPublishModel(): #XXX: this feature is not really described in XEP-0060, we just can see it in examples - # self.features.append("publish_model") # but it's necessary for microblogging comments (see XEP-0277) - - def getFullItem(self, item_data): - """ Attach item configuration to this item - - Used to give item configuration back to node's owner (and *only* to owner) - """ - # TODO: a test should check that only the owner get the item configuration back - - item, item_config = item_data.item, item_data.config - if item_config: - new_item = elementCopy(item) - new_item.addChild(item_config.toElement()) - return new_item - else: - return item - - @defer.inlineCallbacks - def _notifyPublish(self, data): - items_data = data['items_data'] - node = data['node'] - pep = data['pep'] - recipient = data['recipient'] - - owners, notifications_filtered = yield self._prepareNotify(items_data, node, data.get('subscription'), pep, recipient) - - # we notify the owners - # FIXME: check if this comply with XEP-0060 (option needed ?) - # TODO: item's access model have to be sent back to owner - # TODO: same thing for getItems - - for owner_jid in owners: - notifications_filtered.append( - (owner_jid, - {pubsub.Subscription(node.nodeIdentifier, - owner_jid, - 'subscribed')}, - [self.getFullItem(item_data) for item_data in items_data])) - - if pep: - defer.returnValue(self.backend.privilege.notifyPublish( - recipient, - node.nodeIdentifier, - notifications_filtered)) - - else: - defer.returnValue(self.pubsubService.notifyPublish( - self.serviceJID, - node.nodeIdentifier, - notifications_filtered)) - - def _notifyRetract(self, data): - items_data = data['items_data'] - node = data['node'] - pep = data['pep'] - recipient = data['recipient'] - - def afterPrepare(result): - owners, notifications_filtered = result - #we add the owners - - for owner_jid in owners: - notifications_filtered.append( - (owner_jid, - {pubsub.Subscription(node.nodeIdentifier, - owner_jid, - 'subscribed')}, - [item_data.item for item_data in items_data])) - - if pep: - return self.backend.privilege.notifyRetract( - recipient, - node.nodeIdentifier, - notifications_filtered) - - else: - return self.pubsubService.notifyRetract( - self.serviceJID, - node.nodeIdentifier, - notifications_filtered) - - d = self._prepareNotify(items_data, node, data.get('subscription'), pep, recipient) - d.addCallback(afterPrepare) - return d - - @defer.inlineCallbacks - def _prepareNotify(self, items_data, node, subscription=None, pep=None, recipient=None): - """Do a bunch of permissions check and filter notifications - - The owner is not added to these notifications, - it must be added by the calling method - @param items_data(tuple): must contain: - - item (domish.Element) - - access_model (unicode) - - access_list (dict as returned getItemsById, or item_config) - @param node(LeafNode): node hosting the items - @param subscription(pubsub.Subscription, None): TODO - - @return (tuple): will contain: - - notifications_filtered - - node_owner_jid - - items_data - """ - if subscription is None: - notifications = yield self.backend.getNotifications(node, items_data) - else: - notifications = [(subscription.subscriber, [subscription], items_data)] - - if pep and node.getConfiguration()[const.OPT_ACCESS_MODEL] in ('open', 'presence'): - # for PEP we need to manage automatic subscriptions (cf. XEP-0163 §4) - explicit_subscribers = {subscriber for subscriber, _, _ in notifications} - auto_subscribers = yield self.backend.privilege.getAutoSubscribers(recipient, node.nodeIdentifier, explicit_subscribers) - for sub_jid in auto_subscribers: - sub = pubsub.Subscription(node.nodeIdentifier, sub_jid, 'subscribed') - notifications.append((sub_jid, [sub], items_data)) - - owners = yield node.getOwners() - owner_roster = None - - # now we check access of subscriber for each item, and keep only allowed ones - - #we filter items not allowed for the subscribers - notifications_filtered = [] - schema = node.getSchema() - - for subscriber, subscriptions, items_data in notifications: - subscriber_bare = subscriber.userhostJID() - if subscriber_bare in owners: - # as notification is always sent to owner, - # we ignore owner if he is here - continue - allowed_items = [] #we keep only item which subscriber can access - - if schema is not None: - # we have to copy items_data because different subscribers may receive - # different items (e.g. read restriction in schema) - items_data = [itemDataCopy(item_data) for item_data in items_data] - self.backend.filterItemsWithSchema(items_data, schema, False) - - for item_data in items_data: - item, access_model = item_data.item, item_data.access_model - access_list = item_data.config - if access_model == const.VAL_AMODEL_OPEN: - allowed_items.append(item) - elif access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: - if owner_roster is None: - # FIXME: publisher roster should be used, not owner - owner_roster= yield self.backend.getOwnerRoster(node, owners) - if owner_roster is None: - owner_roster = {} - if not subscriber_bare in owner_roster: - continue - #the subscriber is known, is he in the right group ? - authorized_groups = access_list[const.OPT_ROSTER_GROUPS_ALLOWED] - if owner_roster[subscriber_bare].groups.intersection(authorized_groups): - allowed_items.append(item) - else: #unknown access_model - # TODO: white list access - raise NotImplementedError - - if allowed_items: - notifications_filtered.append((subscriber, subscriptions, allowed_items)) - - defer.returnValue((owners, notifications_filtered)) - - def _preDelete(self, data, pep, recipient): - nodeIdentifier = data['node'].nodeIdentifier - redirectURI = data.get('redirectURI', None) - d = self.backend.getSubscribers(nodeIdentifier, pep, recipient) - d.addCallback(lambda subscribers: self.pubsubService.notifyDelete( - self.serviceJID, - nodeIdentifier, - subscribers, - redirectURI)) - return d - - def _mapErrors(self, failure): - e = failure.trap(*self._errorMap.keys()) - - condition, pubsubCondition, feature = self._errorMap[e] - msg = failure.value.msg - - if pubsubCondition: - exc = pubsub.PubSubError(condition, pubsubCondition, feature, msg) - else: - exc = StanzaError(condition, text=msg) - - raise exc - - def getInfo(self, requestor, service, nodeIdentifier, pep=None, recipient=None): - return [] # FIXME: disabled for now, need to manage PEP - if not requestor.resource: - # this avoid error when getting a disco request from server during namespace delegation - return [] - info = {} - - def saveType(result): - info['type'] = result - return nodeIdentifier - - def saveMetaData(result): - info['meta-data'] = result - return info - - def trapNotFound(failure): - failure.trap(error.NodeNotFound) - return info - - d = defer.succeed(nodeIdentifier) - d.addCallback(self.backend.getNodeType) - d.addCallback(saveType) - d.addCallback(self.backend.getNodeMetaData) - d.addCallback(saveMetaData) - d.addErrback(trapNotFound) - d.addErrback(self._mapErrors) - return d - - def getNodes(self, requestor, service, nodeIdentifier): - """return nodes for disco#items - - Pubsub/PEP nodes will be returned if disco node is not specified - else Pubsub/PEP items will be returned - (according to what requestor can access) - """ - try: - pep = service.pep - except AttributeError: - pep = False - - if service.resource: - return defer.succeed([]) - - if nodeIdentifier: - d = self.backend.getItemsIds(nodeIdentifier, - requestor, - [], - requestor.userhostJID() == service, - None, - None, - pep, - service) - # items must be set as name, not node - d.addCallback(lambda items: [(None, item) for item in items]) - - else: - d = self.backend.getNodes(requestor.userhostJID(), - pep, - service) - return d.addErrback(self._mapErrors) - - def getConfigurationOptions(self): - return self.backend.nodeOptions - - def _publish_errb(self, failure, request): - if failure.type == error.NodeNotFound and self.backend.supportsAutoCreate(): - print "Auto-creating node %s" % (request.nodeIdentifier,) - d = self.backend.createNode(request.nodeIdentifier, - request.sender, - pep=self._isPep(request), - recipient=request.recipient) - d.addCallback(lambda ignore, - request: self.backend.publish(request.nodeIdentifier, - request.items, - request.sender, - self._isPep(request), - request.recipient, - ), - request) - return d - - return failure - - def _isPep(self, request): - try: - return request.delegated - except AttributeError: - return False - - def publish(self, request): - d = self.backend.publish(request.nodeIdentifier, - request.items, - request.sender, - self._isPep(request), - request.recipient) - d.addErrback(self._publish_errb, request) - return d.addErrback(self._mapErrors) - - def subscribe(self, request): - d = self.backend.subscribe(request.nodeIdentifier, - request.subscriber, - request.sender, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def unsubscribe(self, request): - d = self.backend.unsubscribe(request.nodeIdentifier, - request.subscriber, - request.sender, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def subscriptions(self, request): - d = self.backend.getSubscriptions(request.sender, - request.nodeIdentifier, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def affiliations(self, request): - """Retrieve affiliation for normal entity (cf. XEP-0060 §5.7) - - retrieve all node where this jid is affiliated - """ - d = self.backend.getAffiliations(request.sender, - request.nodeIdentifier, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def create(self, request): - d = self.backend.createNode(request.nodeIdentifier, - request.sender, request.options, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def default(self, request): - d = self.backend.getDefaultConfiguration(request.nodeType, - self._isPep(request), - request.sender) - return d.addErrback(self._mapErrors) - - def configureGet(self, request): - d = self.backend.getNodeConfiguration(request.nodeIdentifier, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def configureSet(self, request): - d = self.backend.setNodeConfiguration(request.nodeIdentifier, - request.options, - request.sender, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def affiliationsGet(self, request): - """Retrieve affiliations for owner (cf. XEP-0060 §8.9.1) - - retrieve all affiliations for a node - """ - d = self.backend.getAffiliationsOwner(request.nodeIdentifier, - request.sender, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def affiliationsSet(self, request): - d = self.backend.setAffiliationsOwner(request.nodeIdentifier, - request.sender, - request.affiliations, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def subscriptionsGet(self, request): - """Retrieve subscriptions for owner (cf. XEP-0060 §8.8.1) - - retrieve all affiliations for a node - """ - d = self.backend.getSubscriptionsOwner(request.nodeIdentifier, - request.sender, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def subscriptionsSet(self, request): - d = self.backend.setSubscriptionsOwner(request.nodeIdentifier, - request.sender, - request.subscriptions, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def items(self, request): - ext_data = {} - if const.FLAG_ENABLE_RSM and request.rsm is not None: - if request.rsm.max < 0: - raise pubsub.BadRequest(text="max can't be negative") - ext_data['rsm'] = request.rsm - try: - ext_data['pep'] = request.delegated - except AttributeError: - pass - ext_data['order_by'] = request.orderBy or [] - d = self.backend.getItems(request.nodeIdentifier, - request.sender, - request.recipient, - request.maxItems, - request.itemIdentifiers, - ext_data) - return d.addErrback(self._mapErrors) - - def retract(self, request): - d = self.backend.retractItem(request.nodeIdentifier, - request.itemIdentifiers, - request.sender, - request.notify, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def purge(self, request): - d = self.backend.purgeNode(request.nodeIdentifier, - request.sender, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - - def delete(self, request): - d = self.backend.deleteNode(request.nodeIdentifier, - request.sender, - self._isPep(request), - request.recipient) - return d.addErrback(self._mapErrors) - -components.registerAdapter(PubSubResourceFromBackend, - iidavoll.IBackendService, - iwokkel.IPubSubResource) - - - -class ExtraDiscoHandler(XMPPHandler): - implements(iwokkel.IDisco) - # see comment in twisted/plugins/pubsub.py - # FIXME: upstream must be fixed so we can use custom (non pubsub#) disco features - - def getDiscoInfo(self, requestor, service, nodeIdentifier=''): - return [disco.DiscoFeature(pubsub.NS_ORDER_BY)] - - def getDiscoItems(self, requestor, service, nodeIdentifier=''): - return [] diff -r 105a0772eedd -r c56a728412f1 src/const.py --- a/src/const.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,85 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2019 Jérôme Poisson -# Copyright (c) 2013-2016 Adrien Cossa -# Copyright (c) 2003-2011 Ralph Meijer - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -NS_CLIENT = 'jabber:client' -NS_GROUPBLOG_PREFIX = 'urn:xmpp:groupblog:' -NS_ITEM_CONFIG = "http://jabber.org/protocol/pubsub#item-config" -NS_ATOM = "http://www.w3.org/2005/Atom" -NS_FORWARD = 'urn:xmpp:forward:0' -NS_SCHEMA = 'https://salut-a-toi/protocol/schema:0' -NS_SCHEMA_RESTRICT = 'https://salut-a-toi/protocol/schema#restrict:0' - -OPT_ACCESS_MODEL = 'pubsub#access_model' -OPT_ROSTER_GROUPS_ALLOWED = 'pubsub#roster_groups_allowed' -OPT_PERSIST_ITEMS = "pubsub#persist_items" -OPT_DELIVER_PAYLOADS = "pubsub#deliver_payloads" -OPT_SEND_LAST_PUBLISHED_ITEM = "pubsub#send_last_published_item" -OPT_PUBLISH_MODEL = 'pubsub#publish_model' -OPT_SERIAL_IDS = 'pubsub#serial_ids' -OPT_CONSISTENT_PUBLISHER = 'pubsub#consistent_publisher' -VAL_AMODEL_OPEN = 'open' -VAL_AMODEL_PRESENCE = 'presence' -VAL_AMODEL_PUBLISHER_ROSTER = 'publisher-roster' -VAL_AMODEL_WHITELIST = 'whitelist' -VAL_AMODEL_PUBLISH_ONLY = 'publish-only' -VAL_AMODEL_SELF_PUBLISHER = 'self-publisher' -VAL_AMODEL_DEFAULT = VAL_AMODEL_OPEN -VAL_AMODEL_ALL = (VAL_AMODEL_OPEN, VAL_AMODEL_PUBLISHER_ROSTER, VAL_AMODEL_WHITELIST, VAL_AMODEL_PUBLISH_ONLY, VAL_AMODEL_SELF_PUBLISHER) -VAL_PMODEL_PUBLISHERS = 'publishers' -VAL_PMODEL_SUBSCRIBERS = 'subscribers' -VAL_PMODEL_OPEN = 'open' -VAL_PMODEL_DEFAULT = VAL_PMODEL_PUBLISHERS -VAL_RSM_MAX_DEFAULT = 10 # None for no limit -FLAG_ENABLE_RSM = True -FLAG_ENABLE_MAM = True -MAM_FILTER_CATEGORY = 'http://salut-a-toi.org/protocols/mam_filter_category' diff -r 105a0772eedd -r c56a728412f1 src/container.py --- a/src/container.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,24 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (C) 2016 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 . - - -from collections import namedtuple - - -ItemData = namedtuple('ItemData', ('item', 'access_model', 'config', 'categories', 'created', 'updated', 'new')) -ItemData.__new__.__defaults__ = (None,) * (len(ItemData._fields) - 1) # Only item is mandatory diff -r 105a0772eedd -r c56a728412f1 src/delegation.py --- a/src/delegation.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,293 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- -# -# Copyright (c) 2015 Jérôme Poisson - - -# 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 . - -# --- - -# This module implements XEP-0355 (Namespace delegation) to use SàT Pubsub as PEP service - -from wokkel.subprotocols import XMPPHandler -from wokkel import pubsub -from wokkel import data_form -from wokkel import disco, iwokkel -from wokkel.iwokkel import IPubSubService -from wokkel import mam -from twisted.python import log -from twisted.words.protocols.jabber import jid, error -from twisted.words.protocols.jabber.xmlstream import toResponse -from twisted.words.xish import domish -from zope.interface import implements - -DELEGATION_NS = 'urn:xmpp:delegation:1' -FORWARDED_NS = 'urn:xmpp:forward:0' -DELEGATION_ADV_XPATH = '/message/delegation[@xmlns="{}"]'.format(DELEGATION_NS) -DELEGATION_FWD_XPATH = '/iq[@type="set"]/delegation[@xmlns="{}"]/forwarded[@xmlns="{}"]'.format(DELEGATION_NS, FORWARDED_NS) - -DELEGATION_MAIN_SEP = "::" -DELEGATION_BARE_SEP = ":bare:" - -TO_HACK = ((IPubSubService, pubsub, "PubSubRequest"), - (mam.IMAMService, mam, "MAMRequest")) - - -class InvalidStanza(Exception): - pass - - -class DelegationsHandler(XMPPHandler): - implements(iwokkel.IDisco) - _service_hacked = False - - def __init__(self): - super(DelegationsHandler, self).__init__() - - def _service_hack(self): - """Patch the request classes of services to track delegated stanzas""" - # XXX: we need to monkey patch to track origin of the stanza in PubSubRequest. - # As PubSubRequest from sat.tmp.wokkel.pubsub use _request_class while - # original wokkel.pubsub use directly pubsub.PubSubRequest, we need to - # check which version is used before monkeypatching - for handler in self.parent.handlers: - for service, module, default_base_cls in TO_HACK: - if service.providedBy(handler): - if hasattr(handler, '_request_class'): - request_base_class = handler._request_class - else: - request_base_class = getattr(module, default_base_cls) - - class RequestWithDelegation(request_base_class): - """A XxxRequest which put an indicator if the stanza comme from delegation""" - - @classmethod - def fromElement(cls, element): - """Check if element comme from delegation, and set a delegated flags - - delegated flag is either False, or it's a jid of the delegating server - the delegated flag must be set on element before use - """ - try: - # __getattr__ is overriden in domish.Element, so we use __getattribute__ - delegated = element.__getattribute__('delegated') - except AttributeError: - delegated = False - instance = cls.__base__.fromElement(element) - instance.delegated = delegated - return instance - - if hasattr(handler, '_request_class'): - handler._request_class = RequestWithDelegation - else: - setattr(module, default_base_cls, RequestWithDelegation) - DelegationsHandler._service_hacked = True - - def connectionInitialized(self): - if not self._service_hacked: - self._service_hack() - self.xmlstream.addObserver(DELEGATION_ADV_XPATH, self.onAdvertise) - self.xmlstream.addObserver(DELEGATION_FWD_XPATH, self._obsWrapper, 0, self.onForward) - self._current_iqs = {} # dict of iq being handler by delegation - self._xs_send = self.xmlstream.send - self.xmlstream.send = self._sendHack - - def _sendHack(self, elt): - """This method is called instead of xmlstream to control sending - - @param obj(domsish.Element, unicode, str): obj sent to real xmlstream - """ - if isinstance(elt, domish.Element) and elt.name=='iq': - try: - id_ = elt.getAttribute('id') - ori_iq, managed_entity = self._current_iqs[id_] - if jid.JID(elt['to']) != managed_entity: - log.msg("IQ id conflict: the managed entity doesn't match (got {got} was expecting {expected})" - .format(got=jid.JID(elt['to']), expected=managed_entity)) - raise KeyError - except KeyError: - # the iq is not a delegated one - self._xs_send(elt) - else: - del self._current_iqs[id_] - iq_result_elt = toResponse(ori_iq, 'result') - fwd_elt = iq_result_elt.addElement('delegation', DELEGATION_NS).addElement('forwarded', FORWARDED_NS) - fwd_elt.addChild(elt) - elt.uri = elt.defaultUri = 'jabber:client' - self._xs_send(iq_result_elt) - else: - self._xs_send(elt) - - def _obsWrapper(self, observer, stanza): - """Wrapper to observer which catch StanzaError - - @param observer(callable): method to wrap - """ - try: - observer(stanza) - except error.StanzaError as e: - error_elt = e.toResponse(stanza) - self._xs_send(error_elt) - stanza.handled = True - - def onAdvertise(self, message): - """Manage the advertising delegations""" - delegation_elt = message.elements(DELEGATION_NS, 'delegation').next() - delegated = {} - for delegated_elt in delegation_elt.elements(DELEGATION_NS): - try: - if delegated_elt.name != 'delegated': - raise InvalidStanza(u'unexpected element {}'.format(delegated_elt.name)) - try: - namespace = delegated_elt['namespace'] - except KeyError: - raise InvalidStanza(u'was expecting a "namespace" attribute in delegated element') - delegated[namespace] = [] - for attribute_elt in delegated_elt.elements(DELEGATION_NS, 'attribute'): - try: - delegated[namespace].append(attribute_elt["name"]) - except KeyError: - raise InvalidStanza(u'was expecting a "name" attribute in attribute element') - except InvalidStanza as e: - log.msg("Invalid stanza received ({})".format(e)) - - log.msg(u'delegations updated:\n{}'.format( - u'\n'.join([u" - namespace {}{}".format(ns, - u"" if not attributes else u" with filtering on {} attribute(s)".format( - u", ".join(attributes))) for ns, attributes in delegated.items()]))) - - if not pubsub.NS_PUBSUB in delegated: - log.msg(u"Didn't got pubsub delegation from server, can't act as a PEP service") - - def onForward(self, iq): - """Manage forwarded iq - - @param iq(domish.Element): full delegation stanza - """ - - # FIXME: we use a hack supposing that our delegation come from hostname - # and we are a component named [name].hostname - # but we need to manage properly allowed servers - # TODO: do proper origin security check - _, allowed = iq['to'].split('.', 1) - if jid.JID(iq['from']) != jid.JID(allowed): - log.msg((u"SECURITY WARNING: forwarded stanza doesn't come from our server: {}" - .format(iq.toXml())).encode('utf-8')) - raise error.StanzaError('not-allowed') - - try: - fwd_iq = (iq.elements(DELEGATION_NS, 'delegation').next() - .elements(FORWARDED_NS, 'forwarded').next() - .elements('jabber:client', 'iq').next()) - except StopIteration: - raise error.StanzaError('not-acceptable') - - managed_entity = jid.JID(fwd_iq['from']) - - self._current_iqs[fwd_iq['id']] = (iq, managed_entity) - fwd_iq.delegated = True - - # we need a recipient in pubsub request for PEP - # so we set "to" attribute if it doesn't exist - if not fwd_iq.hasAttribute('to'): - fwd_iq["to"] = jid.JID(fwd_iq["from"]).userhost() - - # we now inject the element in the stream - self.xmlstream.dispatch(fwd_iq) - - def getDiscoInfo(self, requestor, target, nodeIdentifier=''): - """Manage disco nesting - - This method looks for DiscoHandler in sibling handlers and use it to - collect main disco infos. It then filters by delegated namespace and return it. - An identity is added for PEP if pubsub namespace is requested. - - The same features/identities are returned for main and bare nodes - """ - if not nodeIdentifier.startswith(DELEGATION_NS): - return [] - - try: - _, namespace = nodeIdentifier.split(DELEGATION_MAIN_SEP, 1) - except ValueError: - try: - _, namespace = nodeIdentifier.split(DELEGATION_BARE_SEP, 1) - except ValueError: - log.msg("Unexpected disco node: {}".format(nodeIdentifier)) - raise error.StanzaError('not-acceptable') - - if not namespace: - log.msg("No namespace found in node {}".format(nodeIdentifier)) - return [] - - if namespace.startswith(pubsub.NS_PUBSUB): - # pubsub use several namespaces starting with NS_PUBSUB (e.g. http://jabber.org/protocol/pubsub#owner) - # we return the same disco for all of them - namespace = pubsub.NS_PUBSUB - - def gotInfos(infos): - ns_features = [] - for info in infos: - if isinstance(info, disco.DiscoFeature) and info.startswith(namespace): - ns_features.append(info) - elif (isinstance(info, data_form.Form) and info.formNamespace - and info.formNamespace.startwith(namespace)): - # extensions management (XEP-0128) - ns_features.append(info) - - if namespace == pubsub.NS_PUBSUB: - ns_features.append(disco.DiscoIdentity('pubsub', 'pep')) - - return ns_features - - for handler in self.parent.handlers: - if isinstance(handler, disco.DiscoHandler): - break - - if not isinstance(handler, disco.DiscoHandler): - log.err("Can't find DiscoHandler") - return [] - - d = handler.info(requestor, target, '') - d.addCallback(gotInfos) - return d - - def getDiscoItems(self, requestor, target, nodeIdentifier=''): - return [] - - -# we monkeypatch DiscoHandler to add delegation informations -def _onDiscoItems(self, iq): - request = disco._DiscoRequest.fromElement(iq) - # it's really ugly to attach pep data to recipient - # but we don't have many options - request.recipient.pep = iq.delegated - - def toResponse(items): - response = disco.DiscoItems() - response.nodeIdentifier = request.nodeIdentifier - - for item in items: - response.append(item) - - return response.toElement() - - d = self.items(request.sender, request.recipient, - request.nodeIdentifier) - d.addCallback(toResponse) - return d - - -disco.DiscoHandler._onDiscoItems = _onDiscoItems diff -r 105a0772eedd -r c56a728412f1 src/error.py --- a/src/error.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,152 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2019 Jérôme Poisson -# Copyright (c) 2013-2016 Adrien Cossa -# Copyright (c) 2003-2011 Ralph Meijer - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -class Error(Exception): - msg = '' - - def __init__(self, msg=None): - self.msg = msg or self.msg - - - def __str__(self): - return self.msg - - -class Deprecated(Exception): - pass - - -class NodeNotFound(Error): - pass - - -class NodeExists(Error): - pass - - -class NotSubscribed(Error): - """ - Entity is not subscribed to this node. - """ - - -class SubscriptionExists(Error): - """ - There already exists a subscription to this node. - """ - - -def NotLeafNodeError(Error): - """a leaf node is expected but we have a collection""" - - -class Forbidden(Error): - pass - - -class NotAuthorized(Error): - pass - - -class NotInRoster(Error): - pass - - -class ItemNotFound(Error): - pass - - -class ItemForbidden(Error): - pass - - -class ItemRequired(Error): - pass - - -class NoInstantNodes(Error): - pass - - -class InvalidConfigurationOption(Error): - msg = 'Invalid configuration option' - - -class InvalidConfigurationValue(Error): - msg = 'Bad configuration value' - - -class NodeNotPersistent(Error): - pass - - -class NoRootNode(Error): - pass - - -class NoCallbacks(Error): - """ - There are no callbacks for this node. - """ - -class NoCollections(Error): - pass - - -class NoPublishing(Error): - """ - This node does not support publishing. - """ - -class BadAccessTypeError(Error): - pass diff -r 105a0772eedd -r c56a728412f1 src/exceptions.py --- a/src/exceptions.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,55 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2019 Jérôme Poisson -# Copyright (c) 2013-2016 Adrien Cossa -# Copyright (c) 2003-2011 Ralph Meijer - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -class InternalError(Exception): - pass diff -r 105a0772eedd -r c56a728412f1 src/gateway.py --- a/src/gateway.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,899 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2019 Jérôme Poisson - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -""" -Web resources and client for interacting with pubsub services. -""" - -import mimetools -from time import gmtime, strftime -from StringIO import StringIO -import urllib -import urlparse - -import simplejson - -from twisted.application import service -from twisted.internet import defer, reactor -from twisted.python import log -from twisted.web import client, http, resource, server -from twisted.web.error import Error -from twisted.words.protocols.jabber.jid import JID -from twisted.words.protocols.jabber.error import StanzaError -from twisted.words.xish import domish - -from wokkel.generic import parseXml -from wokkel.pubsub import Item -from wokkel.pubsub import PubSubClient - -from sat_pubsub import error - -NS_ATOM = 'http://www.w3.org/2005/Atom' -MIME_ATOM_ENTRY = b'application/atom+xml;type=entry' -MIME_ATOM_FEED = b'application/atom+xml;type=feed' -MIME_JSON = b'application/json' - -class XMPPURIParseError(ValueError): - """ - Raised when a given XMPP URI couldn't be properly parsed. - """ - - - -def getServiceAndNode(uri): - """ - Given an XMPP URI, extract the publish subscribe service JID and node ID. - """ - - try: - scheme, rest = uri.split(':', 1) - except ValueError: - raise XMPPURIParseError("No URI scheme component") - - if scheme != 'xmpp': - raise XMPPURIParseError("Unknown URI scheme") - - if rest.startswith("//"): - raise XMPPURIParseError("Unexpected URI authority component") - - try: - entity, query = rest.split('?', 1) - except ValueError: - entity, query = rest, '' - - if not entity: - raise XMPPURIParseError("Empty URI path component") - - try: - service = JID(entity) - except Exception, e: - raise XMPPURIParseError("Invalid JID: %s" % e) - - params = urlparse.parse_qs(query) - - try: - nodeIdentifier = params['node'][0] - except (KeyError, ValueError): - nodeIdentifier = '' - - return service, nodeIdentifier - - - -def getXMPPURI(service, nodeIdentifier): - """ - Construct an XMPP URI from a service JID and node identifier. - """ - return "xmpp:%s?;node=%s" % (service.full(), nodeIdentifier or '') - - - -def _parseContentType(header): - """ - Parse a Content-Type header value to a L{mimetools.Message}. - - L{mimetools.Message} parses a Content-Type header and makes the - components available with its C{getmaintype}, C{getsubtype}, C{gettype}, - C{getplist} and C{getparam} methods. - """ - return mimetools.Message(StringIO(b'Content-Type: ' + header)) - - - -def _asyncResponse(render): - """ - """ - def wrapped(self, request): - def eb(failure): - if failure.check(Error): - err = failure.value - else: - log.err(failure) - err = Error(500) - request.setResponseCode(err.status, err.message) - return err.response - - def finish(result): - if result is server.NOT_DONE_YET: - return - - if result: - request.write(result) - request.finish() - - d = defer.maybeDeferred(render, self, request) - d.addErrback(eb) - d.addCallback(finish) - - return server.NOT_DONE_YET - - return wrapped - - - -class CreateResource(resource.Resource): - """ - A resource to create a publish-subscribe node. - """ - def __init__(self, backend, serviceJID, owner): - self.backend = backend - self.serviceJID = serviceJID - self.owner = owner - - - http_GET = None - - - @_asyncResponse - def render_POST(self, request): - """ - Respond to a POST request to create a new node. - """ - - def toResponse(nodeIdentifier): - uri = getXMPPURI(self.serviceJID, nodeIdentifier) - body = simplejson.dumps({'uri': uri}) - request.setHeader(b'Content-Type', MIME_JSON) - return body - - d = self.backend.createNode(None, self.owner) - d.addCallback(toResponse) - return d - - - -class DeleteResource(resource.Resource): - """ - A resource to create a publish-subscribe node. - """ - def __init__(self, backend, serviceJID, owner): - self.backend = backend - self.serviceJID = serviceJID - self.owner = owner - - - render_GET = None - - - @_asyncResponse - def render_POST(self, request): - """ - Respond to a POST request to create a new node. - """ - def toResponse(result): - request.setResponseCode(http.NO_CONTENT) - - def trapNotFound(failure): - failure.trap(error.NodeNotFound) - raise Error(http.NOT_FOUND, "Node not found") - - if not request.args.get('uri'): - raise Error(http.BAD_REQUEST, "No URI given") - - try: - jid, nodeIdentifier = getServiceAndNode(request.args['uri'][0]) - except XMPPURIParseError, e: - raise Error(http.BAD_REQUEST, "Malformed XMPP URI: %s" % e) - - - data = request.content.read() - if data: - params = simplejson.loads(data) - redirectURI = params.get('redirect_uri', None) - else: - redirectURI = None - - d = self.backend.deleteNode(nodeIdentifier, self.owner, - redirectURI) - d.addCallback(toResponse) - d.addErrback(trapNotFound) - return d - - - -class PublishResource(resource.Resource): - """ - A resource to publish to a publish-subscribe node. - """ - - def __init__(self, backend, serviceJID, owner): - self.backend = backend - self.serviceJID = serviceJID - self.owner = owner - - - render_GET = None - - - def checkMediaType(self, request): - ctype = request.getHeader(b'content-type') - - if not ctype: - request.setResponseCode(http.BAD_REQUEST) - - raise Error(http.BAD_REQUEST, b"No specified Media Type") - - message = _parseContentType(ctype) - if (message.maintype != b'application' or - message.subtype != b'atom+xml' or - message.getparam(b'type') != b'entry' or - (message.getparam(b'charset') or b'utf-8') != b'utf-8'): - raise Error(http.UNSUPPORTED_MEDIA_TYPE, - b"Unsupported Media Type: %s" % ctype) - - - @_asyncResponse - def render_POST(self, request): - """ - Respond to a POST request to create a new item. - """ - - def toResponse(nodeIdentifier): - uri = getXMPPURI(self.serviceJID, nodeIdentifier) - body = simplejson.dumps({'uri': uri}) - request.setHeader(b'Content-Type', MIME_JSON) - return body - - def gotNode(nodeIdentifier, payload): - item = Item(id='current', payload=payload) - d = self.backend.publish(nodeIdentifier, [item], self.owner) - d.addCallback(lambda _: nodeIdentifier) - return d - - def getNode(): - if request.args.get('uri'): - jid, nodeIdentifier = getServiceAndNode(request.args['uri'][0]) - return defer.succeed(nodeIdentifier) - else: - return self.backend.createNode(None, self.owner) - - def trapNotFound(failure): - failure.trap(error.NodeNotFound) - raise Error(http.NOT_FOUND, "Node not found") - - def trapXMPPURIParseError(failure): - failure.trap(XMPPURIParseError) - raise Error(http.BAD_REQUEST, - "Malformed XMPP URI: %s" % failure.value) - - self.checkMediaType(request) - payload = parseXml(request.content.read()) - d = getNode() - d.addCallback(gotNode, payload) - d.addCallback(toResponse) - d.addErrback(trapNotFound) - d.addErrback(trapXMPPURIParseError) - return d - - - -class ListResource(resource.Resource): - def __init__(self, service): - self.service = service - - - @_asyncResponse - def render_GET(self, request): - def responseFromNodes(nodeIdentifiers): - body = simplejson.dumps(nodeIdentifiers) - request.setHeader(b'Content-Type', MIME_JSON) - return body - - d = self.service.getNodes() - d.addCallback(responseFromNodes) - return d - - - -# Service for subscribing to remote XMPP Pubsub nodes and web resources - -def extractAtomEntries(items): - """ - Extract atom entries from a list of publish-subscribe items. - - @param items: List of L{domish.Element}s that represent publish-subscribe - items. - @type items: C{list} - """ - - atomEntries = [] - - for item in items: - # ignore non-items (i.e. retractions) - if item.name != 'item': - continue - - atomEntry = None - for element in item.elements(): - # extract the first element that is an atom entry - if element.uri == NS_ATOM and element.name == 'entry': - atomEntry = element - break - - if atomEntry: - atomEntries.append(atomEntry) - - return atomEntries - - - -def constructFeed(service, nodeIdentifier, entries, title): - nodeURI = getXMPPURI(service, nodeIdentifier) - now = strftime("%Y-%m-%dT%H:%M:%SZ", gmtime()) - - # Collect the received entries in a feed - feed = domish.Element((NS_ATOM, 'feed')) - feed.addElement('title', content=title) - feed.addElement('id', content=nodeURI) - feed.addElement('updated', content=now) - - for entry in entries: - feed.addChild(entry) - - return feed - - - -class RemoteSubscriptionService(service.Service, PubSubClient): - """ - Service for subscribing to remote XMPP Publish-Subscribe nodes. - - Subscriptions are created with a callback HTTP URI that is POSTed - to with the received items in notifications. - """ - - def __init__(self, jid, storage): - self.jid = jid - self.storage = storage - - - def trapNotFound(self, failure): - failure.trap(StanzaError) - - if failure.value.condition == 'item-not-found': - raise error.NodeNotFound() - else: - return failure - - - def subscribeCallback(self, jid, nodeIdentifier, callback): - """ - Subscribe a callback URI. - - This registers a callback URI to be called when a notification is - received for the given node. - - If this is the first callback registered for this node, the gateway - will subscribe to the node. Otherwise, the most recently published item - for this node is retrieved and, if present, the newly registered - callback will be called with that item. - """ - - def callbackForLastItem(items): - atomEntries = extractAtomEntries(items) - - if not atomEntries: - return - - self._postTo([callback], jid, nodeIdentifier, atomEntries[0], - MIME_ATOM_ENTRY) - - def subscribeOrItems(hasCallbacks): - if hasCallbacks: - if not nodeIdentifier: - return None - d = self.items(jid, nodeIdentifier, 1) - d.addCallback(callbackForLastItem) - else: - d = self.subscribe(jid, nodeIdentifier, self.jid) - - d.addErrback(self.trapNotFound) - return d - - d = self.storage.hasCallbacks(jid, nodeIdentifier) - d.addCallback(subscribeOrItems) - d.addCallback(lambda _: self.storage.addCallback(jid, nodeIdentifier, - callback)) - return d - - - def unsubscribeCallback(self, jid, nodeIdentifier, callback): - """ - Unsubscribe a callback. - - If this was the last registered callback for this node, the - gateway will unsubscribe from node. - """ - - def cb(last): - if last: - return self.unsubscribe(jid, nodeIdentifier, self.jid) - - d = self.storage.removeCallback(jid, nodeIdentifier, callback) - d.addCallback(cb) - return d - - - def itemsReceived(self, event): - """ - Fire up HTTP client to do callback - """ - - atomEntries = extractAtomEntries(event.items) - service = event.sender - nodeIdentifier = event.nodeIdentifier - headers = event.headers - - # Don't notify if there are no atom entries - if not atomEntries: - return - - if len(atomEntries) == 1: - contentType = MIME_ATOM_ENTRY - payload = atomEntries[0] - else: - contentType = MIME_ATOM_FEED - payload = constructFeed(service, nodeIdentifier, atomEntries, - title='Received item collection') - - self.callCallbacks(service, nodeIdentifier, payload, contentType) - - if 'Collection' in headers: - for collection in headers['Collection']: - nodeIdentifier = collection or '' - self.callCallbacks(service, nodeIdentifier, payload, - contentType) - - - def deleteReceived(self, event): - """ - Fire up HTTP client to do callback - """ - - service = event.sender - nodeIdentifier = event.nodeIdentifier - redirectURI = event.redirectURI - self.callCallbacks(service, nodeIdentifier, eventType='DELETED', - redirectURI=redirectURI) - - - def _postTo(self, callbacks, service, nodeIdentifier, - payload=None, contentType=None, eventType=None, - redirectURI=None): - - if not callbacks: - return - - postdata = None - nodeURI = getXMPPURI(service, nodeIdentifier) - headers = {'Referer': nodeURI.encode('utf-8'), - 'PubSub-Service': service.full().encode('utf-8')} - - if payload: - postdata = payload.toXml().encode('utf-8') - if contentType: - headers['Content-Type'] = "%s;charset=utf-8" % contentType - - if eventType: - headers['Event'] = eventType - - if redirectURI: - headers['Link'] = '<%s>; rel=alternate' % ( - redirectURI.encode('utf-8'), - ) - - def postNotification(callbackURI): - f = getPageWithFactory(str(callbackURI), - method='POST', - postdata=postdata, - headers=headers) - d = f.deferred - d.addErrback(log.err) - - for callbackURI in callbacks: - reactor.callLater(0, postNotification, callbackURI) - - - def callCallbacks(self, service, nodeIdentifier, - payload=None, contentType=None, eventType=None, - redirectURI=None): - - def eb(failure): - failure.trap(error.NoCallbacks) - - # No callbacks were registered for this node. Unsubscribe? - - d = self.storage.getCallbacks(service, nodeIdentifier) - d.addCallback(self._postTo, service, nodeIdentifier, payload, - contentType, eventType, redirectURI) - d.addErrback(eb) - d.addErrback(log.err) - - - -class RemoteSubscribeBaseResource(resource.Resource): - """ - Base resource for remote pubsub node subscription and unsubscription. - - This resource accepts POST request with a JSON document that holds - a dictionary with the keys C{uri} and C{callback} that respectively map - to the XMPP URI of the publish-subscribe node and the callback URI. - - This class should be inherited with L{serviceMethod} overridden. - - @cvar serviceMethod: The name of the method to be called with - the JID of the pubsub service, the node identifier - and the callback URI as received in the HTTP POST - request to this resource. - """ - serviceMethod = None - errorMap = { - error.NodeNotFound: - (http.FORBIDDEN, "Node not found"), - error.NotSubscribed: - (http.FORBIDDEN, "No such subscription found"), - error.SubscriptionExists: - (http.FORBIDDEN, "Subscription already exists"), - } - - def __init__(self, service): - self.service = service - self.params = None - - - render_GET = None - - - @_asyncResponse - def render_POST(self, request): - def trapNotFound(failure): - err = failure.trap(*self.errorMap.keys()) - status, message = self.errorMap[err] - raise Error(status, message) - - def toResponse(result): - request.setResponseCode(http.NO_CONTENT) - return b'' - - def trapXMPPURIParseError(failure): - failure.trap(XMPPURIParseError) - raise Error(http.BAD_REQUEST, - "Malformed XMPP URI: %s" % failure.value) - - data = request.content.read() - self.params = simplejson.loads(data) - - uri = self.params['uri'] - callback = self.params['callback'] - - jid, nodeIdentifier = getServiceAndNode(uri) - method = getattr(self.service, self.serviceMethod) - d = method(jid, nodeIdentifier, callback) - d.addCallback(toResponse) - d.addErrback(trapNotFound) - d.addErrback(trapXMPPURIParseError) - return d - - - -class RemoteSubscribeResource(RemoteSubscribeBaseResource): - """ - Resource to subscribe to a remote publish-subscribe node. - - The passed C{uri} is the XMPP URI of the node to subscribe to and the - C{callback} is the callback URI. Upon receiving notifications from the - node, a POST request will be perfomed on the callback URI. - """ - serviceMethod = 'subscribeCallback' - - - -class RemoteUnsubscribeResource(RemoteSubscribeBaseResource): - """ - Resource to unsubscribe from a remote publish-subscribe node. - - The passed C{uri} is the XMPP URI of the node to unsubscribe from and the - C{callback} is the callback URI that was registered for it. - """ - serviceMethod = 'unsubscribeCallback' - - - -class RemoteItemsResource(resource.Resource): - """ - Resource for retrieving items from a remote pubsub node. - """ - - def __init__(self, service): - self.service = service - - - @_asyncResponse - def render_GET(self, request): - try: - maxItems = int(request.args.get('max_items', [0])[0]) or None - except ValueError: - raise Error(http.BAD_REQUEST, - "The argument max_items has an invalid value.") - - try: - uri = request.args['uri'][0] - except KeyError: - raise Error(http.BAD_REQUEST, - "No URI for the remote node provided.") - - try: - jid, nodeIdentifier = getServiceAndNode(uri) - except XMPPURIParseError: - raise Error(http.BAD_REQUEST, - "Malformed XMPP URI: %s" % uri) - - def toResponse(items): - """ - Create a feed out the retrieved items. - """ - atomEntries = extractAtomEntries(items) - feed = constructFeed(jid, nodeIdentifier, atomEntries, - "Retrieved item collection") - body = feed.toXml().encode('utf-8') - request.setHeader(b'Content-Type', MIME_ATOM_FEED) - return body - - def trapNotFound(failure): - failure.trap(StanzaError) - if not failure.value.condition == 'item-not-found': - raise failure - raise Error(http.NOT_FOUND, "Node not found") - - d = self.service.items(jid, nodeIdentifier, maxItems) - d.addCallback(toResponse) - d.addErrback(trapNotFound) - return d - - - -# Client side code to interact with a service as provided above - -def getPageWithFactory(url, contextFactory=None, *args, **kwargs): - """Download a web page. - - Download a page. Return the factory that holds a deferred, which will - callback with a page (as a string) or errback with a description of the - error. - - See HTTPClientFactory to see what extra args can be passed. - """ - - factory = client.HTTPClientFactory(url, *args, **kwargs) - factory.protocol.handleStatus_204 = lambda self: self.handleStatus_200() - - if factory.scheme == 'https': - from twisted.internet import ssl - if contextFactory is None: - contextFactory = ssl.ClientContextFactory() - reactor.connectSSL(factory.host, factory.port, factory, contextFactory) - else: - reactor.connectTCP(factory.host, factory.port, factory) - return factory - - - -class CallbackResource(resource.Resource): - """ - Web resource for retrieving gateway notifications. - """ - - def __init__(self, callback): - self.callback = callback - - - http_GET = None - - - def render_POST(self, request): - if request.requestHeaders.hasHeader(b'Event'): - payload = None - else: - payload = parseXml(request.content.read()) - - self.callback(payload, request.requestHeaders) - - request.setResponseCode(http.NO_CONTENT) - return b'' - - - - -class GatewayClient(service.Service): - """ - Service that provides client access to the HTTP Gateway into Idavoll. - """ - - agent = "Idavoll HTTP Gateway Client" - - def __init__(self, baseURI, callbackHost=None, callbackPort=None): - self.baseURI = baseURI - self.callbackHost = callbackHost or 'localhost' - self.callbackPort = callbackPort or 8087 - root = resource.Resource() - root.putChild('callback', CallbackResource( - lambda *args, **kwargs: self.callback(*args, **kwargs))) - self.site = server.Site(root) - - - def startService(self): - self.port = reactor.listenTCP(self.callbackPort, - self.site) - - - def stopService(self): - return self.port.stopListening() - - - def _makeURI(self, verb, query=None): - uriComponents = urlparse.urlparse(self.baseURI) - uri = urlparse.urlunparse((uriComponents[0], - uriComponents[1], - uriComponents[2] + verb, - '', - query and urllib.urlencode(query) or '', - '')) - return uri - - - def callback(self, data, headers): - pass - - - def ping(self): - f = getPageWithFactory(self._makeURI(''), - method='HEAD', - agent=self.agent) - return f.deferred - - - def create(self): - f = getPageWithFactory(self._makeURI('create'), - method='POST', - agent=self.agent) - return f.deferred.addCallback(simplejson.loads) - - - def delete(self, xmppURI, redirectURI=None): - query = {'uri': xmppURI} - - if redirectURI: - params = {'redirect_uri': redirectURI} - postdata = simplejson.dumps(params) - headers = {'Content-Type': MIME_JSON} - else: - postdata = None - headers = None - - f = getPageWithFactory(self._makeURI('delete', query), - method='POST', - postdata=postdata, - headers=headers, - agent=self.agent) - return f.deferred - - - def publish(self, entry, xmppURI=None): - query = xmppURI and {'uri': xmppURI} - - f = getPageWithFactory(self._makeURI('publish', query), - method='POST', - postdata=entry.toXml().encode('utf-8'), - headers={'Content-Type': MIME_ATOM_ENTRY}, - agent=self.agent) - return f.deferred.addCallback(simplejson.loads) - - - def listNodes(self): - f = getPageWithFactory(self._makeURI('list'), - method='GET', - agent=self.agent) - return f.deferred.addCallback(simplejson.loads) - - - def subscribe(self, xmppURI): - params = {'uri': xmppURI, - 'callback': 'http://%s:%s/callback' % (self.callbackHost, - self.callbackPort)} - f = getPageWithFactory(self._makeURI('subscribe'), - method='POST', - postdata=simplejson.dumps(params), - headers={'Content-Type': MIME_JSON}, - agent=self.agent) - return f.deferred - - - def unsubscribe(self, xmppURI): - params = {'uri': xmppURI, - 'callback': 'http://%s:%s/callback' % (self.callbackHost, - self.callbackPort)} - f = getPageWithFactory(self._makeURI('unsubscribe'), - method='POST', - postdata=simplejson.dumps(params), - headers={'Content-Type': MIME_JSON}, - agent=self.agent) - return f.deferred - - - def items(self, xmppURI, maxItems=None): - query = {'uri': xmppURI} - if maxItems is not None: - query['max_items'] = int(maxItems) - f = getPageWithFactory(self._makeURI('items', query), - method='GET', - agent=self.agent) - return f.deferred diff -r 105a0772eedd -r c56a728412f1 src/iidavoll.py --- a/src/iidavoll.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,665 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2019 Jérôme Poisson - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -""" -Interfaces for idavoll. -""" - -from zope.interface import Attribute, Interface - -class IBackendService(Interface): - """ Interface to a backend service of a pubsub service. """ - - - def __init__(storage): - """ - @param storage: Object providing L{IStorage}. - """ - - - def supportsPublisherAffiliation(): - """ Reports if the backend supports the publisher affiliation. - - @rtype: C{bool} - """ - - - def supportsOutcastAffiliation(): - """ Reports if the backend supports the publisher affiliation. - - @rtype: C{bool} - """ - - - def supportsPersistentItems(): - """ Reports if the backend supports persistent items. - - @rtype: C{bool} - """ - - - def getNodeType(nodeIdentifier): - """ Return type of a node. - - @return: a deferred that returns either 'leaf' or 'collection' - """ - - - def getNodes(): - """ Returns list of all nodes. - - @return: a deferred that returns a C{list} of node ids. - """ - - - def getNodeMetaData(nodeIdentifier): - """ Return meta data for a node. - - @return: a deferred that returns a C{list} of C{dict}s with the - metadata. - """ - - - def createNode(nodeIdentifier, requestor): - """ Create a node. - - @return: a deferred that fires when the node has been created. - """ - - - def registerPreDelete(preDeleteFn): - """ Register a callback that is called just before a node deletion. - - The function C{preDeletedFn} is added to a list of functions to be - called just before deletion of a node. The callback C{preDeleteFn} is - called with the C{nodeIdentifier} that is about to be deleted and - should return a deferred that returns a list of deferreds that are to - be fired after deletion. The backend collects the lists from all these - callbacks before actually deleting the node in question. After - deletion all collected deferreds are fired to do post-processing. - - The idea is that you want to be able to collect data from the node - before deleting it, for example to get a list of subscribers that have - to be notified after the node has been deleted. To do this, - C{preDeleteFn} fetches the subscriber list and passes this list to a - callback attached to a deferred that it sets up. This deferred is - returned in the list of deferreds. - """ - - - def deleteNode(nodeIdentifier, requestor): - """ Delete a node. - - @return: a deferred that fires when the node has been deleted. - """ - - - def purgeNode(nodeIdentifier, requestor): - """ Removes all items in node from persistent storage """ - - - def subscribe(nodeIdentifier, subscriber, requestor): - """ Request the subscription of an entity to a pubsub node. - - Depending on the node's configuration and possible business rules, the - C{subscriber} is added to the list of subscriptions of the node with id - C{nodeIdentifier}. The C{subscriber} might be different from the - C{requestor}, and if the C{requestor} is not allowed to subscribe this - entity an exception should be raised. - - @return: a deferred that returns the subscription state - """ - - - def unsubscribe(nodeIdentifier, subscriber, requestor): - """ Cancel the subscription of an entity to a pubsub node. - - The subscription of C{subscriber} is removed from the list of - subscriptions of the node with id C{nodeIdentifier}. If the - C{requestor} is not allowed to unsubscribe C{subscriber}, an an - exception should be raised. - - @return: a deferred that fires when unsubscription is complete. - """ - - - def getSubscribers(nodeIdentifier): - """ Get node subscriber list. - - @return: a deferred that fires with the list of subscribers. - """ - - - def getSubscriptions(entity): - """ Report the list of current subscriptions with this pubsub service. - - Report the list of the current subscriptions with all nodes within this - pubsub service, for the C{entity}. - - @return: a deferred that returns the list of all current subscriptions - as tuples C{(nodeIdentifier, subscriber, subscription)}. - """ - - - def getAffiliations(entity): - """ Report the list of current affiliations with this pubsub service. - - Report the list of the current affiliations with all nodes within this - pubsub service, for the C{entity}. - - @return: a deferred that returns the list of all current affiliations - as tuples C{(nodeIdentifier, affiliation)}. - """ - - - def publish(nodeIdentifier, items, requestor): - """ Publish items to a pubsub node. - - @return: a deferred that fires when the items have been published. - @rtype: L{Deferred} - """ - - - def registerNotifier(observerfn, *args, **kwargs): - """ Register callback which is called for notification. """ - - - def getNotifications(nodeIdentifier, items): - """ - Get notification list. - - This method is called to discover which entities should receive - notifications for the given items that have just been published to the - given node. - - The notification list contains tuples (subscriber, subscriptions, - items) to result in one notification per tuple: the given subscriptions - yielded the given items to be notified to this subscriber. This - structure is needed allow for letting the subscriber know which - subscriptions yielded which notifications, while catering for - collection nodes and content-based subscriptions. - - To minimize the amount of notifications per entity, implementers - should take care that if all items in C{items} were yielded - by the same set of subscriptions, exactly one tuple is for this - subscriber is returned, so that the subscriber would get exactly one - notification. Alternatively, one tuple per subscription combination. - - @param nodeIdentifier: The identifier of the node the items were - published to. - @type nodeIdentifier: C{unicode}. - @param items: The list of published items as - L{Element}s. - @type items: C{list} - @return: The notification list as tuples of - (L{JID}, - C{list} of L{Subscription}, - C{list} of L{Element}. - @rtype: C{list} - """ - - - def getItems(nodeIdentifier, requestor, maxItems=None, itemIdentifiers=[]): - """ Retrieve items from persistent storage - - If C{maxItems} is given, return the C{maxItems} last published - items, else if C{itemIdentifiers} is not empty, return the items - requested. If neither is given, return all items. - - @return: a deferred that returns the requested items - """ - - - def retractItem(nodeIdentifier, itemIdentifier, requestor): - """ Removes item in node from persistent storage """ - - - -class IStorage(Interface): - """ - Storage interface. - """ - - - def getNode(nodeIdentifier): - """ - Get Node. - - @param nodeIdentifier: NodeID of the desired node. - @type nodeIdentifier: C{str} - @return: deferred that returns a L{INode} providing object. - """ - - - def getNodeIds(): - """ - Return all NodeIDs. - - @return: deferred that returns a list of NodeIDs (C{unicode}). - """ - - - def createNode(nodeIdentifier, owner, config): - """ - Create new node. - - The implementation should make sure, the passed owner JID is stripped - of the resource (e.g. using C{owner.userhostJID()}). The passed config - is expected to have values for the fields returned by - L{getDefaultConfiguration}, as well as a value for - C{'pubsub#node_type'}. - - @param nodeIdentifier: NodeID of the new node. - @type nodeIdentifier: C{unicode} - @param owner: JID of the new nodes's owner. - @type owner: L{JID} - @param config: Node configuration. - @type config: C{dict} - @return: deferred that fires on creation. - """ - - - def deleteNode(nodeIdentifier): - """ - Delete a node. - - @param nodeIdentifier: NodeID of the new node. - @type nodeIdentifier: C{unicode} - @return: deferred that fires on deletion. - """ - - - def getAffiliations(entity): - """ - Get all affiliations for entity. - - The implementation should make sure, the passed owner JID is stripped - of the resource (e.g. using C{owner.userhostJID()}). - - @param entity: JID of the entity. - @type entity: L{JID} - @return: deferred that returns a C{list} of tuples of the form - C{(nodeIdentifier, affiliation)}, where C{nodeIdentifier} is - of the type L{unicode} and C{affiliation} is one of - C{'owner'}, C{'publisher'} and C{'outcast'}. - """ - - - def getSubscriptions(entity): - """ - Get all subscriptions for an entity. - - The implementation should make sure, the passed owner JID is stripped - of the resource (e.g. using C{owner.userhostJID()}). - - @param entity: JID of the entity. - @type entity: L{JID} - @return: deferred that returns a C{list} of tuples of the form - C{(nodeIdentifier, subscriber, state)}, where - C{nodeIdentifier} is of the type C{unicode}, C{subscriber} of - the type J{JID}, and - C{state} is C{'subscribed'}, C{'pending'} or - C{'unconfigured'}. - """ - - - def getDefaultConfiguration(nodeType): - """ - Get the default configuration for the given node type. - - @param nodeType: Either C{'leaf'} or C{'collection'}. - @type nodeType: C{str} - @return: The default configuration. - @rtype: C{dict}. - @raises: L{idavoll.error.NoCollections} if collections are not - supported. - """ - - - -class INode(Interface): - """ - Interface to the class of objects that represent nodes. - """ - - nodeType = Attribute("""The type of this node. One of {'leaf'}, - {'collection'}.""") - nodeIdentifier = Attribute("""The node identifer of this node""") - - - def getType(): - """ - Get node's type. - - @return: C{'leaf'} or C{'collection'}. - """ - - - def getConfiguration(): - """ - Get node's configuration. - - The configuration must at least have two options: - C{pubsub#persist_items}, and C{pubsub#deliver_payloads}. - - @return: C{dict} of configuration options. - """ - - - def getMetaData(): - """ - Get node's meta data. - - The meta data must be a superset of the configuration options, and - also at least should have a C{pubsub#node_type} entry. - - @return: C{dict} of meta data. - """ - - - def setConfiguration(options): - """ - Set node's configuration. - - The elements of {options} will set the new values for those - configuration items. This means that only changing items have to - be given. - - @param options: a dictionary of configuration options. - @returns: a deferred that fires upon success. - """ - - - def getAffiliation(entity): - """ - Get affiliation of entity with this node. - - @param entity: JID of entity. - @type entity: L{JID} - @return: deferred that returns C{'owner'}, C{'publisher'}, C{'outcast'} - or C{None}. - """ - - - def getSubscription(subscriber): - """ - Get subscription to this node of subscriber. - - @param subscriber: JID of the new subscriptions' entity. - @type subscriber: L{JID} - @return: deferred that returns the subscription state (C{'subscribed'}, - C{'pending'} or C{None}). - """ - - - def getSubscriptions(state=None): - """ - Get list of subscriptions to this node. - - The optional C{state} argument filters the subscriptions to their - state. - - @param state: Subscription state filter. One of C{'subscribed'}, - C{'pending'}, C{'unconfigured'}. - @type state: C{str} - @return: a deferred that returns a C{list} of - L{wokkel.pubsub.Subscription}s. - """ - - - def addSubscription(subscriber, state, config): - """ - Add new subscription to this node with given state. - - @param subscriber: JID of the new subscriptions' entity. - @type subscriber: L{JID} - @param state: C{'subscribed'} or C{'pending'} - @type state: C{str} - @param config: Subscription configuration. - @param config: C{dict} - @return: deferred that fires on subscription. - """ - - - def removeSubscription(subscriber): - """ - Remove subscription to this node. - - @param subscriber: JID of the subscriptions' entity. - @type subscriber: L{JID} - @return: deferred that fires on removal. - """ - - - def isSubscribed(entity): - """ - Returns whether entity has any subscription to this node. - - Only returns C{True} when the subscription state (if present) is - C{'subscribed'} for any subscription that matches the bare JID. - - @param subscriber: bare JID of the subscriptions' entity. - @type subscriber: L{JID} - @return: deferred that returns a C{bool}. - """ - - - def getAffiliations(): - """ - Get affiliations of entities with this node. - - @return: deferred that returns a C{list} of tuples (jid, affiliation), - where jid is a L(JID) - and affiliation is one of C{'owner'}, - C{'publisher'}, C{'outcast'}. - """ - - - -class ILeafNode(Interface): - """ - Interface to the class of objects that represent leaf nodes. - """ - - def storeItems(items, publisher): - """ - Store items in persistent storage for later retrieval. - - @param items: The list of items to be stored. Each item is the - L{domish} representation of the XML fragment as defined - for C{} in the - C{http://jabber.org/protocol/pubsub} namespace. - @type items: C{list} of {domish.Element} - @param publisher: JID of the publishing entity. - @type publisher: L{JID} - @return: deferred that fires upon success. - """ - - - def removeItems(itemIdentifiers): - """ - Remove items by id. - - @param itemIdentifiers: C{list} of item ids. - @return: deferred that fires with a C{list} of ids of the items that - were deleted - """ - - - def getItems(authorized_groups, unrestricted, maxItems=None): - """ Get all authorised items - If C{maxItems} is not given, all authorised items in the node are returned, - just like C{getItemsById}. Otherwise, C{maxItems} limits - the returned items to a maximum of that number of most recently - published and authorised items. - - @param authorized_groups: we want to get items that these groups can access - @param unrestricted: if true, don't check permissions (i.e.: get all items) - @param maxItems: if given, a natural number (>0) that limits the - returned number of items. - @return: deferred that fires a C{list} of (item, access_model, id) - if unrestricted is True, else a C{list} of items. - """ - - - def countItems(authorized_groups, unrestricted): - """ Count the accessible items. - - @param authorized_groups: we want to get items that these groups can access. - @param unrestricted: if true, don't check permissions (i.e.: get all items). - @return: deferred that fires a C{int}. - """ - - - def getIndex(authorized_groups, unrestricted, item): - """ Retrieve the index of the given item within the accessible window. - - @param authorized_groups: we want to get items that these groups can access. - @param unrestricted: if true, don't check permissions (i.e.: get all items). - @param item: item identifier. - @return: deferred that fires a C{int}. - """ - - def getItemsById(authorized_groups, unrestricted, itemIdentifiers): - """ - Get items by item id. - - Each item in the returned list is a unicode string that - represent the XML of the item as it was published, including the - item wrapper with item id. - - @param authorized_groups: we want to get items that these groups can access - @param unrestricted: if true, don't check permissions - @param itemIdentifiers: C{list} of item ids. - @return: deferred that fires a C{list} of (item, access_model, id) - if unrestricted is True, else a C{list} of items. - """ - - - def purge(): - """ - Purge node of all items in persistent storage. - - @return: deferred that fires when the node has been purged. - """ - - - def filterItemsWithPublisher(itemIdentifiers, requestor): - """ - Filter the given items by checking the items publisher against the requestor. - - @param itemIdentifiers: C{list} of item ids. - @param requestor: JID of the requestor. - @type requestor: L{JID} - @return: deferred that fires with a C{list} of item identifiers. - """ - -class IGatewayStorage(Interface): - - def addCallback(service, nodeIdentifier, callback): - """ - Register a callback URI. - - The registered HTTP callback URI will have an Atom Entry documented - POSTed to it upon receiving a notification for the given pubsub node. - - @param service: The XMPP entity that holds the node. - @type service: L{JID} - @param nodeIdentifier: The identifier of the publish-subscribe node. - @type nodeIdentifier: C{unicode}. - @param callback: The callback URI to be registered. - @type callback: C{str}. - @rtype: L{Deferred} - """ - - def removeCallback(service, nodeIdentifier, callback): - """ - Remove a registered callback URI. - - The returned deferred will fire with a boolean that signals wether or - not this was the last callback unregistered for this node. - - @param service: The XMPP entity that holds the node. - @type service: L{JID} - @param nodeIdentifier: The identifier of the publish-subscribe node. - @type nodeIdentifier: C{unicode}. - @param callback: The callback URI to be unregistered. - @type callback: C{str}. - @rtype: L{Deferred} - """ - - def getCallbacks(service, nodeIdentifier): - """ - Get the callbacks registered for this node. - - Returns a deferred that fires with the set of HTTP callback URIs - registered for this node. - - @param service: The XMPP entity that holds the node. - @type service: L{JID} - @param nodeIdentifier: The identifier of the publish-subscribe node. - @type nodeIdentifier: C{unicode}. - @rtype: L{Deferred} - """ - - - def hasCallbacks(service, nodeIdentifier): - """ - Return wether there are callbacks registered for a node. - - @param service: The XMPP entity that holds the node. - @type service: L{JID} - @param nodeIdentifier: The identifier of the publish-subscribe node. - @type nodeIdentifier: C{unicode}. - @returns: Deferred that fires with a boolean. - @rtype: L{Deferred} - """ diff -r 105a0772eedd -r c56a728412f1 src/mam.py --- a/src/mam.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,181 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2016 Jérôme Poisson -# Copyright (c) 2015-2016 Adrien Cossa -# -# 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 . - -""" -XMPP Message Archive Management protocol. - -This protocol is specified in -U{XEP-0313}. -""" - - -from zope.interface import implements - -from twisted.words.xish import domish -from twisted.python import log -from twisted.words.protocols.jabber import error - -from sat_pubsub import const -from sat_pubsub import backend -from wokkel import pubsub - -from wokkel import rsm -from wokkel import mam -from wokkel import delay - - -class MAMResource(object): - implements(mam.IMAMResource) - _errorMap = backend.PubSubResourceFromBackend._errorMap - - def __init__(self, backend_): - self.backend = backend_ - - def _mapErrors(self, failure): - # XXX: come from backend.PubsubResourceFromBackend - e = failure.trap(*self._errorMap.keys()) - - condition, pubsubCondition, feature = self._errorMap[e] - msg = failure.value.msg - - if pubsubCondition: - exc = pubsub.PubSubError(condition, pubsubCondition, feature, msg) - else: - exc = error.StanzaError(condition, text=msg) - - raise exc - - def onArchiveRequest(self, mam_request): - """ - - @param mam_request: The MAM archive request. - @type mam_request: L{MAMQueryReques} - - @return: A tuple with list of message data (id, element, data) and RSM element - @rtype: C{tuple} - """ - # FIXME: bad result ordering - try: - pep = mam_request.delegated - except AttributeError: - pep = False - ext_data = {'pep': pep} - if mam_request.form: - ext_data['filters'] = mam_request.form.fields.values() - if mam_request.rsm is None: - if const.VAL_RSM_MAX_DEFAULT != None: - log.msg("MAM request without RSM limited to {}".format(const.VAL_RSM_MAX_DEFAULT)) - ext_data['rsm'] = rsm.RSMRequest(const.VAL_RSM_MAX_DEFAULT) - else: - ext_data['rsm'] = mam_request.rsm - - if mam_request.orderBy: - ext_data['order_by'] = mam_request.orderBy - - d = self.backend.getItemsData(mam_request.node, mam_request.sender, - mam_request.recipient, None, None, ext_data) - - def make_message(elt): - # XXX: http://xmpp.org/extensions/xep-0297.html#sect-idp629952 (rule 3) - message = domish.Element((const.NS_CLIENT, "message")) - event = message.addElement((pubsub.NS_PUBSUB_EVENT, "event")) - items = event.addElement('items') - items["node"] = mam_request.node - items.addChild(elt) - return message - - def cb(items_data): - msg_data = [] - rsm_elt = None - attributes = {} - for item_data in items_data: - if item_data.item.name == 'set' and item_data.item.uri == rsm.NS_RSM: - assert rsm_elt is None - rsm_elt = item_data.item - if rsm_elt.first: - # XXX: we check if it is the last page using initial request data - # and RSM element data. In this case, we must have the - # "complete" - # attribute set to "true". - page_max = (int(rsm_elt.first['index']) + 1) * mam_request.rsm.max - count = int(unicode(rsm_elt.count)) - if page_max >= count: - # the maximum items which can be displayed is equal to or - # above the total number of items, which means we are complete - attributes['complete'] = "true" - else: - log.msg("WARNING: no element in RSM request: {xml}".format( - xml = rsm_elt.toXml().encode('utf-8'))) - elif item_data.item.name == 'item': - msg_data.append([item_data.item['id'], make_message(item_data.item), - item_data.created]) - else: - log.msg("WARNING: unknown element: {}".format(item_data.item.name)) - if pep: - # we need to send privileged message - # so me manage the sending ourself, and return - # an empty msg_data list to avoid double sending - for data in msg_data: - self.forwardPEPMessage(mam_request, *data) - msg_data = [] - return (msg_data, rsm_elt, attributes) - - d.addErrback(self._mapErrors) - d.addCallback(cb) - return d - - def forwardPEPMessage(self, mam_request, id_, elt, created): - msg = domish.Element((None, 'message')) - msg['from'] = self.backend.privilege.server_jid.full() - msg['to'] = mam_request.sender.full() - result = msg.addElement((mam.NS_MAM, 'result')) - if mam_request.query_id is not None: - result['queryid'] = mam_request.query_id - result['id'] = id_ - forward = result.addElement((const.NS_FORWARD, 'forwarded')) - forward.addChild(delay.Delay(created).toElement()) - forward.addChild(elt) - self.backend.privilege.sendMessage(msg) - - def onPrefsGetRequest(self, requestor): - """ - - @param requestor: JID of the requestor. - @type requestor: L{JID} - - @return: The current settings. - @rtype: L{wokkel.mam.MAMPrefs} - """ - # TODO: return the actual current settings - return mam.MAMPrefs() - - def onPrefsSetRequest(self, prefs, requestor): - """ - - @param prefs: The new settings to set. - @type prefs: L{wokkel.mam.MAMPrefs} - - @param requestor: JID of the requestor. - @type requestor: L{JID} - - @return: The settings that have actually been set. - @rtype: L{wokkel.mam.MAMPrefs} - """ - # TODO: set the new settings and return them - return mam.MAMPrefs() diff -r 105a0772eedd -r c56a728412f1 src/memory_storage.py --- a/src/memory_storage.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,380 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2019 Jérôme Poisson - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -import copy -from zope.interface import implements -from twisted.internet import defer -from twisted.words.protocols.jabber import jid - -from wokkel.pubsub import Subscription - -from sat_pubsub import error, iidavoll - -class Storage: - - implements(iidavoll.IStorage) - - defaultConfig = { - 'leaf': { - "pubsub#persist_items": True, - "pubsub#deliver_payloads": True, - "pubsub#send_last_published_item": 'on_sub', - }, - 'collection': { - "pubsub#deliver_payloads": True, - "pubsub#send_last_published_item": 'on_sub', - } - } - - def __init__(self): - rootNode = CollectionNode('', jid.JID('localhost'), - copy.copy(self.defaultConfig['collection'])) - self._nodes = {'': rootNode} - - - def getNode(self, nodeIdentifier): - try: - node = self._nodes[nodeIdentifier] - except KeyError: - return defer.fail(error.NodeNotFound()) - - return defer.succeed(node) - - - def getNodeIds(self): - return defer.succeed(self._nodes.keys()) - - - def createNode(self, nodeIdentifier, owner, config): - if nodeIdentifier in self._nodes: - return defer.fail(error.NodeExists()) - - if config['pubsub#node_type'] != 'leaf': - raise error.NoCollections() - - node = LeafNode(nodeIdentifier, owner, config) - self._nodes[nodeIdentifier] = node - - return defer.succeed(None) - - - def deleteNode(self, nodeIdentifier): - try: - del self._nodes[nodeIdentifier] - except KeyError: - return defer.fail(error.NodeNotFound()) - - return defer.succeed(None) - - - def getAffiliations(self, entity): - entity = entity.userhost() - return defer.succeed([(node.nodeIdentifier, node._affiliations[entity]) - for name, node in self._nodes.iteritems() - if entity in node._affiliations]) - - - def getSubscriptions(self, entity): - subscriptions = [] - for node in self._nodes.itervalues(): - for subscriber, subscription in node._subscriptions.iteritems(): - subscriber = jid.internJID(subscriber) - if subscriber.userhostJID() == entity.userhostJID(): - subscriptions.append(subscription) - - return defer.succeed(subscriptions) - - - def getDefaultConfiguration(self, nodeType): - if nodeType == 'collection': - raise error.NoCollections() - - return self.defaultConfig[nodeType] - - -class Node: - - implements(iidavoll.INode) - - def __init__(self, nodeIdentifier, owner, config): - self.nodeIdentifier = nodeIdentifier - self._affiliations = {owner.userhost(): 'owner'} - self._subscriptions = {} - self._config = copy.copy(config) - - - def getType(self): - return self.nodeType - - - def getConfiguration(self): - return self._config - - - def getMetaData(self): - config = copy.copy(self._config) - config["pubsub#node_type"] = self.nodeType - return config - - - def setConfiguration(self, options): - for option in options: - if option in self._config: - self._config[option] = options[option] - - return defer.succeed(None) - - - def getAffiliation(self, entity): - return defer.succeed(self._affiliations.get(entity.userhost())) - - - def getSubscription(self, subscriber): - try: - subscription = self._subscriptions[subscriber.full()] - except KeyError: - return defer.succeed(None) - else: - return defer.succeed(subscription) - - - def getSubscriptions(self, state=None): - return defer.succeed( - [subscription - for subscription in self._subscriptions.itervalues() - if state is None or subscription.state == state]) - - - - def addSubscription(self, subscriber, state, options): - if self._subscriptions.get(subscriber.full()): - return defer.fail(error.SubscriptionExists()) - - subscription = Subscription(self.nodeIdentifier, subscriber, state, - options) - self._subscriptions[subscriber.full()] = subscription - return defer.succeed(None) - - - def removeSubscription(self, subscriber): - try: - del self._subscriptions[subscriber.full()] - except KeyError: - return defer.fail(error.NotSubscribed()) - - return defer.succeed(None) - - - def isSubscribed(self, entity): - for subscriber, subscription in self._subscriptions.iteritems(): - if jid.internJID(subscriber).userhost() == entity.userhost() and \ - subscription.state == 'subscribed': - return defer.succeed(True) - - return defer.succeed(False) - - - def getAffiliations(self): - affiliations = [(jid.internJID(entity), affiliation) for entity, affiliation - in self._affiliations.iteritems()] - - return defer.succeed(affiliations) - - - -class PublishedItem(object): - """ - A published item. - - This represent an item as it was published by an entity. - - @ivar element: The DOM representation of the item that was published. - @type element: L{Element} - @ivar publisher: The entity that published the item. - @type publisher: L{JID} - """ - - def __init__(self, element, publisher): - self.element = element - self.publisher = publisher - - - -class LeafNode(Node): - - implements(iidavoll.ILeafNode) - - nodeType = 'leaf' - - def __init__(self, nodeIdentifier, owner, config): - Node.__init__(self, nodeIdentifier, owner, config) - self._items = {} - self._itemlist = [] - - - def storeItems(self, item_data, publisher): - for access_model, item_config, element in item_data: - item = PublishedItem(element, publisher) - itemIdentifier = element["id"] - if itemIdentifier in self._items: - self._itemlist.remove(self._items[itemIdentifier]) - self._items[itemIdentifier] = item - self._itemlist.append(item) - - return defer.succeed(None) - - - def removeItems(self, itemIdentifiers): - deleted = [] - - for itemIdentifier in itemIdentifiers: - try: - item = self._items[itemIdentifier] - except KeyError: - pass - else: - self._itemlist.remove(item) - del self._items[itemIdentifier] - deleted.append(itemIdentifier) - - return defer.succeed(deleted) - - - def getItems(self, authorized_groups, unrestricted, maxItems=None): - if maxItems is not None: - itemList = self._itemlist[-maxItems:] - else: - itemList = self._itemlist - return defer.succeed([item.element for item in itemList]) - - - def getItemsById(self, authorized_groups, unrestricted, itemIdentifiers): - items = [] - for itemIdentifier in itemIdentifiers: - try: - item = self._items[itemIdentifier] - except KeyError: - pass - else: - items.append(item.element) - return defer.succeed(items) - - - def purge(self): - self._items = {} - self._itemlist = [] - - return defer.succeed(None) - - - def filterItemsWithPublisher(self, itemIdentifiers, requestor): - filteredItems = [] - for itemIdentifier in itemIdentifiers: - try: - if self._items[itemIdentifier].publisher.userhost() == requestor.userhost(): - filteredItems.append(self.items[itemIdentifier]) - except KeyError, AttributeError: - pass - return defer.succeed(filteredItems) - - -class CollectionNode(Node): - nodeType = 'collection' - - - -class GatewayStorage(object): - """ - Memory based storage facility for the XMPP-HTTP gateway. - """ - - def __init__(self): - self.callbacks = {} - - - def addCallback(self, service, nodeIdentifier, callback): - try: - callbacks = self.callbacks[service, nodeIdentifier] - except KeyError: - callbacks = {callback} - self.callbacks[service, nodeIdentifier] = callbacks - else: - callbacks.add(callback) - pass - - return defer.succeed(None) - - - def removeCallback(self, service, nodeIdentifier, callback): - try: - callbacks = self.callbacks[service, nodeIdentifier] - callbacks.remove(callback) - except KeyError: - return defer.fail(error.NotSubscribed()) - else: - if not callbacks: - del self.callbacks[service, nodeIdentifier] - - return defer.succeed(not callbacks) - - - def getCallbacks(self, service, nodeIdentifier): - try: - callbacks = self.callbacks[service, nodeIdentifier] - except KeyError: - return defer.fail(error.NoCallbacks()) - else: - return defer.succeed(callbacks) - - - def hasCallbacks(self, service, nodeIdentifier): - return defer.succeed((service, nodeIdentifier) in self.callbacks) diff -r 105a0772eedd -r c56a728412f1 src/pgsql_storage.py --- a/src/pgsql_storage.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1379 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2019 Jérôme Poisson -# Copyright (c) 2013-2016 Adrien Cossa -# Copyright (c) 2003-2011 Ralph Meijer - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -import copy, logging - -from zope.interface import implements - -from twisted.internet import reactor -from twisted.internet import defer -from twisted.words.protocols.jabber import jid -from twisted.python import log - -from wokkel import generic -from wokkel.pubsub import Subscription - -from sat_pubsub import error -from sat_pubsub import iidavoll -from sat_pubsub import const -from sat_pubsub import container -from sat_pubsub import exceptions -import uuid -import psycopg2 -import psycopg2.extensions -# we wants psycopg2 to return us unicode, not str -psycopg2.extensions.register_type(psycopg2.extensions.UNICODE) -psycopg2.extensions.register_type(psycopg2.extensions.UNICODEARRAY) - -# parseXml manage str, but we get unicode -parseXml = lambda unicode_data: generic.parseXml(unicode_data.encode('utf-8')) -ITEMS_SEQ_NAME = u'node_{node_id}_seq' -PEP_COL_NAME = 'pep' -CURRENT_VERSION = '5' -# retrieve the maximum integer item id + 1 -NEXT_ITEM_ID_QUERY = r"SELECT COALESCE(max(item::integer)+1,1) as val from items where node_id={node_id} and item ~ E'^\\d+$'" - - -def withPEP(query, values, pep, recipient): - """Helper method to facilitate PEP management - - @param query: SQL query basis - @param values: current values to replace in query - @param pep(bool): True if we are in PEP mode - @param recipient(jid.JID): jid of the recipient - @return: query + PEP AND check, - recipient's bare jid is added to value if needed - """ - if pep: - pep_check="AND {}=%s".format(PEP_COL_NAME) - values=list(values) + [recipient.userhost()] - else: - pep_check="AND {} IS NULL".format(PEP_COL_NAME) - return "{} {}".format(query, pep_check), values - - -class Storage: - - implements(iidavoll.IStorage) - - defaultConfig = { - 'leaf': { - const.OPT_PERSIST_ITEMS: True, - const.OPT_DELIVER_PAYLOADS: True, - const.OPT_SEND_LAST_PUBLISHED_ITEM: 'on_sub', - const.OPT_ACCESS_MODEL: const.VAL_AMODEL_DEFAULT, - const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_DEFAULT, - const.OPT_SERIAL_IDS: False, - const.OPT_CONSISTENT_PUBLISHER: False, - }, - 'collection': { - const.OPT_DELIVER_PAYLOADS: True, - const.OPT_SEND_LAST_PUBLISHED_ITEM: 'on_sub', - const.OPT_ACCESS_MODEL: const.VAL_AMODEL_DEFAULT, - const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_DEFAULT, - } - } - - def __init__(self, dbpool): - self.dbpool = dbpool - d = self.dbpool.runQuery("SELECT value FROM metadata WHERE key='version'") - d.addCallbacks(self._checkVersion, self._versionEb) - - def _checkVersion(self, row): - version = row[0].value - if version != CURRENT_VERSION: - logging.error("Bad database schema version ({current}), please upgrade to {needed}".format( - current=version, needed=CURRENT_VERSION)) - reactor.stop() - - def _versionEb(self, failure): - logging.error("Can't check schema version: {reason}".format(reason=failure)) - reactor.stop() - - def _buildNode(self, row): - """Build a note class from database result row""" - configuration = {} - - if not row: - raise error.NodeNotFound() - - if row[2] == 'leaf': - configuration = { - 'pubsub#persist_items': row[3], - 'pubsub#deliver_payloads': row[4], - 'pubsub#send_last_published_item': row[5], - const.OPT_ACCESS_MODEL:row[6], - const.OPT_PUBLISH_MODEL:row[7], - const.OPT_SERIAL_IDS:row[8], - const.OPT_CONSISTENT_PUBLISHER:row[9], - } - schema = row[10] - if schema is not None: - schema = parseXml(schema) - node = LeafNode(row[0], row[1], configuration, schema) - node.dbpool = self.dbpool - return node - elif row[2] == 'collection': - configuration = { - 'pubsub#deliver_payloads': row[4], - 'pubsub#send_last_published_item': row[5], - const.OPT_ACCESS_MODEL: row[6], - const.OPT_PUBLISH_MODEL:row[7], - } - node = CollectionNode(row[0], row[1], configuration, None) - node.dbpool = self.dbpool - return node - else: - raise ValueError("Unknown node type !") - - def getNodeById(self, nodeDbId): - """Get node using database ID insted of pubsub identifier - - @param nodeDbId(unicode): database ID - """ - return self.dbpool.runInteraction(self._getNodeById, nodeDbId) - - def _getNodeById(self, cursor, nodeDbId): - cursor.execute("""SELECT node_id, - node, - node_type, - persist_items, - deliver_payloads, - send_last_published_item, - access_model, - publish_model, - serial_ids, - consistent_publisher, - schema::text, - pep - FROM nodes - WHERE node_id=%s""", - (nodeDbId,)) - row = cursor.fetchone() - return self._buildNode(row) - - def getNode(self, nodeIdentifier, pep, recipient=None): - return self.dbpool.runInteraction(self._getNode, nodeIdentifier, pep, recipient) - - def _getNode(self, cursor, nodeIdentifier, pep, recipient): - cursor.execute(*withPEP("""SELECT node_id, - node, - node_type, - persist_items, - deliver_payloads, - send_last_published_item, - access_model, - publish_model, - serial_ids, - consistent_publisher, - schema::text, - pep - FROM nodes - WHERE node=%s""", - (nodeIdentifier,), pep, recipient)) - row = cursor.fetchone() - return self._buildNode(row) - - def getNodeIds(self, pep, recipient, allowed_accesses=None): - """retrieve ids of existing nodes - - @param pep(bool): True if it's a PEP request - @param recipient(jid.JID, None): recipient of the PEP request - @param allowed_accesses(None, set): only nodes with access - in this set will be returned - None to return all nodes - @return (list[unicode]): ids of nodes - """ - if not pep: - query = "SELECT node from nodes WHERE pep is NULL" - values = [] - else: - query = "SELECT node from nodes WHERE pep=%s" - values = [recipient.userhost()] - - if allowed_accesses is not None: - query += "AND access_model IN %s" - values.append(tuple(allowed_accesses)) - - d = self.dbpool.runQuery(query, values) - d.addCallback(lambda results: [r[0] for r in results]) - return d - - def createNode(self, nodeIdentifier, owner, config, schema, pep, recipient=None): - return self.dbpool.runInteraction(self._createNode, nodeIdentifier, - owner, config, schema, pep, recipient) - - def _createNode(self, cursor, nodeIdentifier, owner, config, schema, pep, recipient): - if config['pubsub#node_type'] != 'leaf': - raise error.NoCollections() - - owner = owner.userhost() - - try: - cursor.execute("""INSERT INTO nodes - (node, - node_type, - persist_items, - deliver_payloads, - send_last_published_item, - access_model, - publish_model, - serial_ids, - consistent_publisher, - schema, - pep) - VALUES - (%s, 'leaf', %s, %s, %s, %s, %s, %s, %s, %s, %s)""", - (nodeIdentifier, - config['pubsub#persist_items'], - config['pubsub#deliver_payloads'], - config['pubsub#send_last_published_item'], - config[const.OPT_ACCESS_MODEL], - config[const.OPT_PUBLISH_MODEL], - config[const.OPT_SERIAL_IDS], - config[const.OPT_CONSISTENT_PUBLISHER], - schema, - recipient.userhost() if pep else None - ) - ) - except cursor._pool.dbapi.IntegrityError as e: - if e.pgcode == "23505": - # unique_violation - raise error.NodeExists() - else: - raise error.InvalidConfigurationOption() - - cursor.execute(*withPEP("""SELECT node_id FROM nodes WHERE node=%s""", - (nodeIdentifier,), pep, recipient)); - node_id = cursor.fetchone()[0] - - cursor.execute("""SELECT 1 as bool from entities where jid=%s""", - (owner,)) - - if not cursor.fetchone(): - # XXX: we can NOT rely on the previous query! Commit is needed now because - # if the entry exists the next query will leave the database in a corrupted - # state: the solution is to rollback. I tried with other methods like - # "WHERE NOT EXISTS" but none of them worked, so the following solution - # looks like the sole - unless you have auto-commit on. More info - # about this issue: http://cssmay.com/question/tag/tag-psycopg2 - cursor.connection.commit() - try: - cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", - (owner,)) - except psycopg2.IntegrityError as e: - cursor.connection.rollback() - logging.warning("during node creation: %s" % e.message) - - cursor.execute("""INSERT INTO affiliations - (node_id, entity_id, affiliation) - SELECT %s, entity_id, 'owner' FROM - (SELECT entity_id FROM entities - WHERE jid=%s) as e""", - (node_id, owner)) - - if config[const.OPT_ACCESS_MODEL] == const.VAL_AMODEL_PUBLISHER_ROSTER: - if const.OPT_ROSTER_GROUPS_ALLOWED in config: - allowed_groups = config[const.OPT_ROSTER_GROUPS_ALLOWED] - else: - allowed_groups = [] - for group in allowed_groups: - #TODO: check that group are actually in roster - cursor.execute("""INSERT INTO node_groups_authorized (node_id, groupname) - VALUES (%s,%s)""" , (node_id, group)) - # XXX: affiliations can't be set on during node creation (at least not with XEP-0060 alone) - # so whitelist affiliations need to be done afterward - - # no we may have to do extra things according to config options - default_conf = self.defaultConfig['leaf'] - # XXX: trigger works on node creation because OPT_SERIAL_IDS is False in defaultConfig - # if this value is changed, the _configurationTriggers method should be adapted. - Node._configurationTriggers(cursor, node_id, default_conf, config) - - def deleteNodeByDbId(self, db_id): - """Delete a node using directly its database id""" - return self.dbpool.runInteraction(self._deleteNodeByDbId, db_id) - - def _deleteNodeByDbId(self, cursor, db_id): - cursor.execute("""DELETE FROM nodes WHERE node_id=%s""", - (db_id,)) - - if cursor.rowcount != 1: - raise error.NodeNotFound() - - def deleteNode(self, nodeIdentifier, pep, recipient=None): - return self.dbpool.runInteraction(self._deleteNode, nodeIdentifier, pep, recipient) - - def _deleteNode(self, cursor, nodeIdentifier, pep, recipient): - cursor.execute(*withPEP("""DELETE FROM nodes WHERE node=%s""", - (nodeIdentifier,), pep, recipient)) - - if cursor.rowcount != 1: - raise error.NodeNotFound() - - def getAffiliations(self, entity, nodeIdentifier, pep, recipient=None): - return self.dbpool.runInteraction(self._getAffiliations, entity, nodeIdentifier, pep, recipient) - - def _getAffiliations(self, cursor, entity, nodeIdentifier, pep, recipient=None): - query = ["""SELECT node, affiliation FROM entities - NATURAL JOIN affiliations - NATURAL JOIN nodes - WHERE jid=%s"""] - args = [entity.userhost()] - - if nodeIdentifier is not None: - query.append("AND node=%s") - args.append(nodeIdentifier) - - cursor.execute(*withPEP(' '.join(query), args, pep, recipient)) - rows = cursor.fetchall() - return [tuple(r) for r in rows] - - def getSubscriptions(self, entity, nodeIdentifier=None, pep=False, recipient=None): - """retrieve subscriptions of an entity - - @param entity(jid.JID): entity to check - @param nodeIdentifier(unicode, None): node identifier - None to retrieve all subscriptions - @param pep: True if we are in PEP mode - @param recipient: jid of the recipient - """ - - def toSubscriptions(rows): - subscriptions = [] - for row in rows: - subscriber = jid.internJID('%s/%s' % (row.jid, - row.resource)) - subscription = Subscription(row.node, subscriber, row.state) - subscriptions.append(subscription) - return subscriptions - - query = ["""SELECT node, - jid, - resource, - state - FROM entities - NATURAL JOIN subscriptions - NATURAL JOIN nodes - WHERE jid=%s"""] - - args = [entity.userhost()] - - if nodeIdentifier is not None: - query.append("AND node=%s") - args.append(nodeIdentifier) - - d = self.dbpool.runQuery(*withPEP(' '.join(query), args, pep, recipient)) - d.addCallback(toSubscriptions) - return d - - def getDefaultConfiguration(self, nodeType): - return self.defaultConfig[nodeType].copy() - - def formatLastItems(self, result): - last_items = [] - for pep_jid_s, node, data, item_access_model in result: - pep_jid = jid.JID(pep_jid_s) - item = generic.stripNamespace(parseXml(data)) - last_items.append((pep_jid, node, item, item_access_model)) - return last_items - - def getLastItems(self, entities, nodes, node_accesses, item_accesses, pep): - """get last item for several nodes and entities in a single request""" - if not entities or not nodes or not node_accesses or not item_accesses: - raise ValueError("entities, nodes and accesses must not be empty") - if node_accesses != ('open',) or item_accesses != ('open',): - raise NotImplementedError('only "open" access model is handled for now') - if not pep: - raise NotImplementedError(u"getLastItems is only implemented for PEP at the moment") - d = self.dbpool.runQuery("""SELECT DISTINCT ON (node_id) pep, node, data::text, items.access_model - FROM items - NATURAL JOIN nodes - WHERE nodes.pep IN %s - AND node IN %s - AND nodes.access_model in %s - AND items.access_model in %s - ORDER BY node_id DESC, items.updated DESC""", - (tuple([e.userhost() for e in entities]), - nodes, - node_accesses, - item_accesses)) - d.addCallback(self.formatLastItems) - return d - - -class Node: - - implements(iidavoll.INode) - - def __init__(self, nodeDbId, nodeIdentifier, config, schema): - self.nodeDbId = nodeDbId - self.nodeIdentifier = nodeIdentifier - self._config = config - self._schema = schema - - def _checkNodeExists(self, cursor): - cursor.execute("""SELECT 1 as exist FROM nodes WHERE node_id=%s""", - (self.nodeDbId,)) - if not cursor.fetchone(): - raise error.NodeNotFound() - - def getType(self): - return self.nodeType - - def getOwners(self): - d = self.dbpool.runQuery("""SELECT jid FROM nodes NATURAL JOIN affiliations NATURAL JOIN entities WHERE node_id=%s and affiliation='owner'""", (self.nodeDbId,)) - d.addCallback(lambda rows: [jid.JID(r[0]) for r in rows]) - return d - - def getConfiguration(self): - return self._config - - def getNextId(self): - """return XMPP item id usable for next item to publish - - the return value will be next int if serila_ids is set, - else an UUID will be returned - """ - if self._config[const.OPT_SERIAL_IDS]: - d = self.dbpool.runQuery("SELECT nextval('{seq_name}')".format( - seq_name = ITEMS_SEQ_NAME.format(node_id=self.nodeDbId))) - d.addCallback(lambda rows: unicode(rows[0][0])) - return d - else: - return defer.succeed(unicode(uuid.uuid4())) - - @staticmethod - def _configurationTriggers(cursor, node_id, old_config, new_config): - """trigger database relative actions needed when a config is changed - - @param cursor(): current db cursor - @param node_id(unicode): database ID of the node - @param old_config(dict): config of the node before the change - @param new_config(dict): new options that will be changed - """ - serial_ids = new_config[const.OPT_SERIAL_IDS] - if serial_ids != old_config[const.OPT_SERIAL_IDS]: - # serial_ids option has been modified, - # we need to handle corresponding sequence - - # XXX: we use .format in following queries because values - # are generated by ourself - seq_name = ITEMS_SEQ_NAME.format(node_id=node_id) - if serial_ids: - # the next query get the max value +1 of all XMPP items ids - # which are integers, and default to 1 - cursor.execute(NEXT_ITEM_ID_QUERY.format(node_id=node_id)) - next_val = cursor.fetchone()[0] - cursor.execute("DROP SEQUENCE IF EXISTS {seq_name}".format(seq_name = seq_name)) - cursor.execute("CREATE SEQUENCE {seq_name} START {next_val} OWNED BY nodes.node_id".format( - seq_name = seq_name, - next_val = next_val)) - else: - cursor.execute("DROP SEQUENCE IF EXISTS {seq_name}".format(seq_name = seq_name)) - - def setConfiguration(self, options): - config = copy.copy(self._config) - - for option in options: - if option in config: - config[option] = options[option] - - d = self.dbpool.runInteraction(self._setConfiguration, config) - d.addCallback(self._setCachedConfiguration, config) - return d - - def _setConfiguration(self, cursor, config): - self._checkNodeExists(cursor) - self._configurationTriggers(cursor, self.nodeDbId, self._config, config) - cursor.execute("""UPDATE nodes SET persist_items=%s, - deliver_payloads=%s, - send_last_published_item=%s, - access_model=%s, - publish_model=%s, - serial_ids=%s, - consistent_publisher=%s - WHERE node_id=%s""", - (config[const.OPT_PERSIST_ITEMS], - config[const.OPT_DELIVER_PAYLOADS], - config[const.OPT_SEND_LAST_PUBLISHED_ITEM], - config[const.OPT_ACCESS_MODEL], - config[const.OPT_PUBLISH_MODEL], - config[const.OPT_SERIAL_IDS], - config[const.OPT_CONSISTENT_PUBLISHER], - self.nodeDbId)) - - def _setCachedConfiguration(self, void, config): - self._config = config - - def getSchema(self): - return self._schema - - def setSchema(self, schema): - d = self.dbpool.runInteraction(self._setSchema, schema) - d.addCallback(self._setCachedSchema, schema) - return d - - def _setSchema(self, cursor, schema): - self._checkNodeExists(cursor) - cursor.execute("""UPDATE nodes SET schema=%s - WHERE node_id=%s""", - (schema.toXml() if schema else None, - self.nodeDbId)) - - def _setCachedSchema(self, void, schema): - self._schema = schema - - def getMetaData(self): - config = copy.copy(self._config) - config["pubsub#node_type"] = self.nodeType - return config - - def getAffiliation(self, entity): - return self.dbpool.runInteraction(self._getAffiliation, entity) - - def _getAffiliation(self, cursor, entity): - self._checkNodeExists(cursor) - cursor.execute("""SELECT affiliation FROM affiliations - NATURAL JOIN nodes - NATURAL JOIN entities - WHERE node_id=%s AND jid=%s""", - (self.nodeDbId, - entity.userhost())) - - try: - return cursor.fetchone()[0] - except TypeError: - return None - - def getAccessModel(self): - return self._config[const.OPT_ACCESS_MODEL] - - def getSubscription(self, subscriber): - return self.dbpool.runInteraction(self._getSubscription, subscriber) - - def _getSubscription(self, cursor, subscriber): - self._checkNodeExists(cursor) - - userhost = subscriber.userhost() - resource = subscriber.resource or '' - - cursor.execute("""SELECT state FROM subscriptions - NATURAL JOIN nodes - NATURAL JOIN entities - WHERE node_id=%s AND jid=%s AND resource=%s""", - (self.nodeDbId, - userhost, - resource)) - - row = cursor.fetchone() - if not row: - return None - else: - return Subscription(self.nodeIdentifier, subscriber, row[0]) - - def getSubscriptions(self, state=None): - return self.dbpool.runInteraction(self._getSubscriptions, state) - - def _getSubscriptions(self, cursor, state): - self._checkNodeExists(cursor) - - query = """SELECT node, jid, resource, state, - subscription_type, subscription_depth - FROM subscriptions - NATURAL JOIN nodes - NATURAL JOIN entities - WHERE node_id=%s""" - values = [self.nodeDbId] - - if state: - query += " AND state=%s" - values.append(state) - - cursor.execute(query, values) - rows = cursor.fetchall() - - subscriptions = [] - for row in rows: - subscriber = jid.JID(u'%s/%s' % (row.jid, row.resource)) - - options = {} - if row.subscription_type: - options['pubsub#subscription_type'] = row.subscription_type; - if row.subscription_depth: - options['pubsub#subscription_depth'] = row.subscription_depth; - - subscriptions.append(Subscription(row.node, subscriber, - row.state, options)) - - return subscriptions - - def addSubscription(self, subscriber, state, config): - return self.dbpool.runInteraction(self._addSubscription, subscriber, - state, config) - - def _addSubscription(self, cursor, subscriber, state, config): - self._checkNodeExists(cursor) - - userhost = subscriber.userhost() - resource = subscriber.resource or '' - - subscription_type = config.get('pubsub#subscription_type') - subscription_depth = config.get('pubsub#subscription_depth') - - try: - cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", - (userhost,)) - except cursor._pool.dbapi.IntegrityError: - cursor.connection.rollback() - - try: - cursor.execute("""INSERT INTO subscriptions - (node_id, entity_id, resource, state, - subscription_type, subscription_depth) - SELECT %s, entity_id, %s, %s, %s, %s FROM - (SELECT entity_id FROM entities - WHERE jid=%s) AS ent_id""", - (self.nodeDbId, - resource, - state, - subscription_type, - subscription_depth, - userhost)) - except cursor._pool.dbapi.IntegrityError: - raise error.SubscriptionExists() - - def removeSubscription(self, subscriber): - return self.dbpool.runInteraction(self._removeSubscription, - subscriber) - - def _removeSubscription(self, cursor, subscriber): - self._checkNodeExists(cursor) - - userhost = subscriber.userhost() - resource = subscriber.resource or '' - - cursor.execute("""DELETE FROM subscriptions WHERE - node_id=%s AND - entity_id=(SELECT entity_id FROM entities - WHERE jid=%s) AND - resource=%s""", - (self.nodeDbId, - userhost, - resource)) - if cursor.rowcount != 1: - raise error.NotSubscribed() - - return None - - def setSubscriptions(self, subscriptions): - return self.dbpool.runInteraction(self._setSubscriptions, subscriptions) - - def _setSubscriptions(self, cursor, subscriptions): - self._checkNodeExists(cursor) - - entities = self.getOrCreateEntities(cursor, [s.subscriber for s in subscriptions]) - entities_map = {jid.JID(e.jid): e for e in entities} - - # then we construct values for subscriptions update according to entity_id we just got - placeholders = ','.join(len(subscriptions) * ["%s"]) - values = [] - for subscription in subscriptions: - entity_id = entities_map[subscription.subscriber].entity_id - resource = subscription.subscriber.resource or u'' - values.append((self.nodeDbId, entity_id, resource, subscription.state, None, None)) - # we use upsert so new values are inserted and existing one updated. This feature is only available for PostgreSQL >= 9.5 - cursor.execute("INSERT INTO subscriptions(node_id, entity_id, resource, state, subscription_type, subscription_depth) VALUES " + placeholders + " ON CONFLICT (entity_id, resource, node_id) DO UPDATE SET state=EXCLUDED.state", [v for v in values]) - - def isSubscribed(self, entity): - return self.dbpool.runInteraction(self._isSubscribed, entity) - - def _isSubscribed(self, cursor, entity): - self._checkNodeExists(cursor) - - cursor.execute("""SELECT 1 as bool FROM entities - NATURAL JOIN subscriptions - NATURAL JOIN nodes - WHERE entities.jid=%s - AND node_id=%s AND state='subscribed'""", - (entity.userhost(), - self.nodeDbId)) - - return cursor.fetchone() is not None - - def getAffiliations(self): - return self.dbpool.runInteraction(self._getAffiliations) - - def _getAffiliations(self, cursor): - self._checkNodeExists(cursor) - - cursor.execute("""SELECT jid, affiliation FROM nodes - NATURAL JOIN affiliations - NATURAL JOIN entities - WHERE node_id=%s""", - (self.nodeDbId,)) - result = cursor.fetchall() - - return {jid.internJID(r[0]): r[1] for r in result} - - def getOrCreateEntities(self, cursor, entities_jids): - """Get entity_id from entities in entities table - - Entities will be inserted it they don't exist - @param entities_jid(list[jid.JID]): entities to get or create - @return list[record(entity_id,jid)]]: list of entity_id and jid (as plain string) - both existing and inserted entities are returned - """ - # cf. http://stackoverflow.com/a/35265559 - placeholders = ','.join(len(entities_jids) * ["(%s)"]) - query = ( - """ - WITH - jid_values (jid) AS ( - VALUES {placeholders} - ), - inserted (entity_id, jid) AS ( - INSERT INTO entities (jid) - SELECT jid - FROM jid_values - ON CONFLICT DO NOTHING - RETURNING entity_id, jid - ) - SELECT e.entity_id, e.jid - FROM entities e JOIN jid_values jv ON jv.jid = e.jid - UNION ALL - SELECT entity_id, jid - FROM inserted""".format(placeholders=placeholders)) - cursor.execute(query, [j.userhost() for j in entities_jids]) - return cursor.fetchall() - - def setAffiliations(self, affiliations): - return self.dbpool.runInteraction(self._setAffiliations, affiliations) - - def _setAffiliations(self, cursor, affiliations): - self._checkNodeExists(cursor) - - entities = self.getOrCreateEntities(cursor, affiliations) - - # then we construct values for affiliations update according to entity_id we just got - placeholders = ','.join(len(affiliations) * ["(%s,%s,%s)"]) - values = [] - map(values.extend, ((e.entity_id, affiliations[jid.JID(e.jid)], self.nodeDbId) for e in entities)) - - # we use upsert so new values are inserted and existing one updated. This feature is only available for PostgreSQL >= 9.5 - cursor.execute("INSERT INTO affiliations(entity_id,affiliation,node_id) VALUES " + placeholders + " ON CONFLICT (entity_id,node_id) DO UPDATE SET affiliation=EXCLUDED.affiliation", values) - - def deleteAffiliations(self, entities): - return self.dbpool.runInteraction(self._deleteAffiliations, entities) - - def _deleteAffiliations(self, cursor, entities): - """delete affiliations and subscriptions for this entity""" - self._checkNodeExists(cursor) - placeholders = ','.join(len(entities) * ["%s"]) - cursor.execute("DELETE FROM affiliations WHERE node_id=%s AND entity_id in (SELECT entity_id FROM entities WHERE jid IN (" + placeholders + ")) RETURNING entity_id", [self.nodeDbId] + [e.userhost() for e in entities]) - - rows = cursor.fetchall() - placeholders = ','.join(len(rows) * ["%s"]) - cursor.execute("DELETE FROM subscriptions WHERE node_id=%s AND entity_id in (" + placeholders + ")", [self.nodeDbId] + [r[0] for r in rows]) - - def getAuthorizedGroups(self): - return self.dbpool.runInteraction(self._getNodeGroups) - - def _getAuthorizedGroups(self, cursor): - cursor.execute("SELECT groupname FROM node_groups_authorized NATURAL JOIN nodes WHERE node=%s", - (self.nodeDbId,)) - rows = cursor.fetchall() - return [row[0] for row in rows] - - -class LeafNode(Node): - - implements(iidavoll.ILeafNode) - - nodeType = 'leaf' - - def getOrderBy(self, ext_data, direction='DESC'): - """Return ORDER BY clause corresponding to Order By key in ext_data - - @param ext_data (dict): extra data as used in getItems - @param direction (unicode): ORDER BY direction (ASC or DESC) - @return (unicode): ORDER BY clause to use - """ - keys = ext_data.get('order_by') - if not keys: - return u'ORDER BY updated ' + direction - cols_statmnt = [] - for key in keys: - if key == 'creation': - column = 'item_id' # could work with items.created too - elif key == 'modification': - column = 'updated' - else: - log.msg(u"WARNING: Unknown order by key: {key}".format(key=key)) - column = 'updated' - cols_statmnt.append(column + u' ' + direction) - - return u"ORDER BY " + u",".join([col for col in cols_statmnt]) - - @defer.inlineCallbacks - def storeItems(self, items_data, publisher): - # XXX: runInteraction doesn't seem to work when there are several "insert" - # or "update". - # Before the unpacking was done in _storeItems, but this was causing trouble - # in case of multiple items_data. So this has now be moved here. - # FIXME: investigate the issue with runInteraction - for item_data in items_data: - yield self.dbpool.runInteraction(self._storeItems, item_data, publisher) - - def _storeItems(self, cursor, item_data, publisher): - self._checkNodeExists(cursor) - self._storeItem(cursor, item_data, publisher) - - def _storeItem(self, cursor, item_data, publisher): - # first try to insert the item - # - if it fails (conflict), and the item is new and we have serial_ids options, - # current id will be recomputed using next item id query (note that is not perfect, as - # table is not locked and this can fail if two items are added at the same time - # but this can only happen with serial_ids and if future ids have been set by a client, - # this case should be rare enough to consider this situation acceptable) - # - if item insertion fail and the item is not new, we do an update - # - in other cases, exception is raised - item, access_model, item_config = item_data.item, item_data.access_model, item_data.config - data = item.toXml() - - insert_query = """INSERT INTO items (node_id, item, publisher, data, access_model) - SELECT %s, %s, %s, %s, %s FROM nodes - WHERE node_id=%s - RETURNING item_id""" - insert_data = [self.nodeDbId, - item["id"], - publisher.full(), - data, - access_model, - self.nodeDbId] - - try: - cursor.execute(insert_query, insert_data) - except cursor._pool.dbapi.IntegrityError as e: - if e.pgcode != "23505": - # we only handle unique_violation, every other exception must be raised - raise e - cursor.connection.rollback() - # the item already exist - if item_data.new: - # the item is new - if self._config[const.OPT_SERIAL_IDS]: - # this can happen with serial_ids, if a item has been stored - # with a future id (generated by XMPP client) - cursor.execute(NEXT_ITEM_ID_QUERY.format(node_id=self.nodeDbId)) - next_id = cursor.fetchone()[0] - # we update the sequence, so we can skip conflicting ids - cursor.execute(u"SELECT setval('{seq_name}', %s)".format( - seq_name = ITEMS_SEQ_NAME.format(node_id=self.nodeDbId)), [next_id]) - # and now we can retry the query with the new id - item['id'] = insert_data[1] = unicode(next_id) - # item saved in DB must also be updated with the new id - insert_data[3] = item.toXml() - cursor.execute(insert_query, insert_data) - else: - # but if we have not serial_ids, we have a real problem - raise e - else: - # this is an update - cursor.execute("""UPDATE items SET updated=now(), publisher=%s, data=%s - FROM nodes - WHERE nodes.node_id = items.node_id AND - nodes.node_id = %s and items.item=%s - RETURNING item_id""", - (publisher.full(), - data, - self.nodeDbId, - item["id"])) - if cursor.rowcount != 1: - raise exceptions.InternalError("item has not been updated correctly") - item_id = cursor.fetchone()[0]; - self._storeCategories(cursor, item_id, item_data.categories, update=True) - return - - item_id = cursor.fetchone()[0]; - self._storeCategories(cursor, item_id, item_data.categories) - - if access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: - if const.OPT_ROSTER_GROUPS_ALLOWED in item_config: - item_config.fields[const.OPT_ROSTER_GROUPS_ALLOWED].fieldType='list-multi' #XXX: needed to force list if there is only one value - allowed_groups = item_config[const.OPT_ROSTER_GROUPS_ALLOWED] - else: - allowed_groups = [] - for group in allowed_groups: - #TODO: check that group are actually in roster - cursor.execute("""INSERT INTO item_groups_authorized (item_id, groupname) - VALUES (%s,%s)""" , (item_id, group)) - # TODO: whitelist access model - - def _storeCategories(self, cursor, item_id, categories, update=False): - # TODO: handle canonical form - if update: - cursor.execute("""DELETE FROM item_categories - WHERE item_id=%s""", (item_id,)) - - # we use a set to avoid duplicates - for category in set(categories): - cursor.execute("""INSERT INTO item_categories (item_id, category) - VALUES (%s, %s)""", (item_id, category)) - - def removeItems(self, itemIdentifiers): - return self.dbpool.runInteraction(self._removeItems, itemIdentifiers) - - def _removeItems(self, cursor, itemIdentifiers): - self._checkNodeExists(cursor) - - deleted = [] - - for itemIdentifier in itemIdentifiers: - cursor.execute("""DELETE FROM items WHERE - node_id=%s AND - item=%s""", - (self.nodeDbId, - itemIdentifier)) - - if cursor.rowcount: - deleted.append(itemIdentifier) - - return deleted - - def getItems(self, authorized_groups, unrestricted, maxItems=None, ext_data=None): - """ Get all authorised items - - @param authorized_groups: we want to get items that these groups can access - @param unrestricted: if true, don't check permissions (i.e.: get all items) - @param maxItems: nb of items we want to get - @param ext_data: options for extra features like RSM and MAM - - @return: list of container.ItemData - if unrestricted is False, access_model and config will be None - """ - if ext_data is None: - ext_data = {} - return self.dbpool.runInteraction(self._getItems, authorized_groups, unrestricted, maxItems, ext_data, ids_only=False) - - def getItemsIds(self, authorized_groups, unrestricted, maxItems=None, ext_data=None): - """ Get all authorised items ids - - @param authorized_groups: we want to get items that these groups can access - @param unrestricted: if true, don't check permissions (i.e.: get all items) - @param maxItems: nb of items we want to get - @param ext_data: options for extra features like RSM and MAM - - @return list(unicode): list of ids - """ - if ext_data is None: - ext_data = {} - return self.dbpool.runInteraction(self._getItems, authorized_groups, unrestricted, maxItems, ext_data, ids_only=True) - - def _appendSourcesAndFilters(self, query, args, authorized_groups, unrestricted, ext_data): - """append sources and filters to sql query requesting items and return ORDER BY - - arguments query, args, authorized_groups, unrestricted and ext_data are the same as for - _getItems - """ - # SOURCES - query.append("FROM nodes INNER JOIN items USING (node_id)") - - if unrestricted: - query_filters = ["WHERE node_id=%s"] - args.append(self.nodeDbId) - else: - query.append("LEFT JOIN item_groups_authorized USING (item_id)") - args.append(self.nodeDbId) - if authorized_groups: - get_groups = " or (items.access_model='roster' and groupname in %s)" - args.append(authorized_groups) - else: - get_groups = "" - - query_filters = ["WHERE node_id=%s AND (items.access_model='open'" + get_groups + ")"] - - # FILTERS - if 'filters' in ext_data: # MAM filters - for filter_ in ext_data['filters']: - if filter_.var == 'start': - query_filters.append("AND created>=%s") - args.append(filter_.value) - elif filter_.var == 'end': - query_filters.append("AND created<=%s") - args.append(filter_.value) - elif filter_.var == 'with': - jid_s = filter_.value - if '/' in jid_s: - query_filters.append("AND publisher=%s") - args.append(filter_.value) - else: - query_filters.append("AND publisher LIKE %s") - args.append(u"{}%".format(filter_.value)) - elif filter_.var == const.MAM_FILTER_CATEGORY: - query.append("LEFT JOIN item_categories USING (item_id)") - query_filters.append("AND category=%s") - args.append(filter_.value) - else: - log.msg("WARNING: unknown filter: {}".format(filter_.encode('utf-8'))) - - query.extend(query_filters) - - return self.getOrderBy(ext_data) - - def _getItems(self, cursor, authorized_groups, unrestricted, maxItems, ext_data, ids_only): - self._checkNodeExists(cursor) - - if maxItems == 0: - return [] - - args = [] - - # SELECT - if ids_only: - query = ["SELECT item"] - else: - query = ["SELECT data::text,items.access_model,item_id,created,updated"] - - query_order = self._appendSourcesAndFilters(query, args, authorized_groups, unrestricted, ext_data) - - if 'rsm' in ext_data: - rsm = ext_data['rsm'] - maxItems = rsm.max - if rsm.index is not None: - # We need to know the item_id of corresponding to the index (offset) of the current query - # so we execute the query to look for the item_id - tmp_query = query[:] - tmp_args = args[:] - tmp_query[0] = "SELECT item_id" - tmp_query.append("{} LIMIT 1 OFFSET %s".format(query_order)) - tmp_args.append(rsm.index) - cursor.execute(' '.join(query), args) - # FIXME: bad index is not managed yet - item_id = cursor.fetchall()[0][0] - - # now that we have the id, we can use it - query.append("AND item_id<=%s") - args.append(item_id) - elif rsm.before is not None: - if rsm.before != '': - query.append("AND item_id>(SELECT item_id FROM items WHERE item=%s LIMIT 1)") - args.append(rsm.before) - if maxItems is not None: - # if we have maxItems (i.e. a limit), we need to reverse order - # in a first query to get the right items - query.insert(0,"SELECT * from (") - query.append(self.getOrderBy(ext_data, direction='ASC')) - query.append("LIMIT %s) as x") - args.append(maxItems) - elif rsm.after: - query.append("AND item_id<(SELECT item_id FROM items WHERE item=%s LIMIT 1)") - args.append(rsm.after) - - query.append(query_order) - - if maxItems is not None: - query.append("LIMIT %s") - args.append(maxItems) - - cursor.execute(' '.join(query), args) - - result = cursor.fetchall() - if unrestricted and not ids_only: - # with unrestricted query, we need to fill the access_list for a roster access items - ret = [] - for item_data in result: - item = generic.stripNamespace(parseXml(item_data.data)) - access_model = item_data.access_model - item_id = item_data.item_id - created = item_data.created - updated = item_data.updated - access_list = {} - if access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: - cursor.execute('SELECT groupname FROM item_groups_authorized WHERE item_id=%s', (item_id,)) - access_list[const.OPT_ROSTER_GROUPS_ALLOWED] = [r.groupname for r in cursor.fetchall()] - - ret.append(container.ItemData(item, access_model, access_list, created=created, updated=updated)) - # TODO: whitelist item access model - return ret - - if ids_only: - return [r.item for r in result] - else: - items_data = [container.ItemData(generic.stripNamespace(parseXml(r.data)), r.access_model, created=r.created, updated=r.updated) for r in result] - return items_data - - def getItemsById(self, authorized_groups, unrestricted, itemIdentifiers): - """Get items which are in the given list - - @param authorized_groups: we want to get items that these groups can access - @param unrestricted: if true, don't check permissions - @param itemIdentifiers: list of ids of the items we want to get - @return: list of container.ItemData - ItemData.config will contains access_list (managed as a dictionnary with same key as for item_config) - if unrestricted is False, access_model and config will be None - """ - return self.dbpool.runInteraction(self._getItemsById, authorized_groups, unrestricted, itemIdentifiers) - - def _getItemsById(self, cursor, authorized_groups, unrestricted, itemIdentifiers): - self._checkNodeExists(cursor) - ret = [] - if unrestricted: #we get everything without checking permissions - for itemIdentifier in itemIdentifiers: - cursor.execute("""SELECT data::text,items.access_model,item_id,created,updated FROM nodes - INNER JOIN items USING (node_id) - WHERE node_id=%s AND item=%s""", - (self.nodeDbId, - itemIdentifier)) - result = cursor.fetchone() - if not result: - raise error.ItemNotFound() - - item = generic.stripNamespace(parseXml(result[0])) - access_model = result[1] - item_id = result[2] - created= result[3] - updated= result[4] - access_list = {} - if access_model == const.VAL_AMODEL_PUBLISHER_ROSTER: - cursor.execute('SELECT groupname FROM item_groups_authorized WHERE item_id=%s', (item_id,)) - access_list[const.OPT_ROSTER_GROUPS_ALLOWED] = [r[0] for r in cursor.fetchall()] - #TODO: WHITELIST access_model - - ret.append(container.ItemData(item, access_model, access_list, created=created, updated=updated)) - else: #we check permission before returning items - for itemIdentifier in itemIdentifiers: - args = [self.nodeDbId, itemIdentifier] - if authorized_groups: - args.append(authorized_groups) - cursor.execute("""SELECT data::text, created, updated FROM nodes - INNER JOIN items USING (node_id) - LEFT JOIN item_groups_authorized USING (item_id) - WHERE node_id=%s AND item=%s AND - (items.access_model='open' """ + - ("or (items.access_model='roster' and groupname in %s)" if authorized_groups else '') + ")", - args) - - result = cursor.fetchone() - if result: - ret.append(container.ItemData(generic.stripNamespace(parseXml(result[0])), created=result[1], updated=result[2])) - - return ret - - def getItemsCount(self, authorized_groups, unrestricted, ext_data=None): - """Count expected number of items in a getItems query - - @param authorized_groups: we want to get items that these groups can access - @param unrestricted: if true, don't check permissions (i.e.: get all items) - @param ext_data: options for extra features like RSM and MAM - """ - if ext_data is None: - ext_data = {} - return self.dbpool.runInteraction(self._getItemsCount, authorized_groups, unrestricted, ext_data) - - def _getItemsCount(self, cursor, authorized_groups, unrestricted, ext_data): - self._checkNodeExists(cursor) - args = [] - - # SELECT - query = ["SELECT count(1)"] - - self._appendSourcesAndFilters(query, args, authorized_groups, unrestricted, ext_data) - - cursor.execute(' '.join(query), args) - return cursor.fetchall()[0][0] - - def getItemsIndex(self, item_id, authorized_groups, unrestricted, ext_data=None): - """Get expected index of first item in the window of a getItems query - - @param item_id: id of the item - @param authorized_groups: we want to get items that these groups can access - @param unrestricted: if true, don't check permissions (i.e.: get all items) - @param ext_data: options for extra features like RSM and MAM - """ - if ext_data is None: - ext_data = {} - return self.dbpool.runInteraction(self._getItemsIndex, item_id, authorized_groups, unrestricted, ext_data) - - def _getItemsIndex(self, cursor, item_id, authorized_groups, unrestricted, ext_data): - self._checkNodeExists(cursor) - args = [] - - # SELECT - query = [] - - query_order = self._appendSourcesAndFilters(query, args, authorized_groups, unrestricted, ext_data) - - query_select = "SELECT row_number from (SELECT row_number() OVER ({}), item".format(query_order) - query.insert(0, query_select) - query.append(") as x WHERE item=%s") - args.append(item_id) - - cursor.execute(' '.join(query), args) - # XXX: row_number start at 1, but we want that index start at 0 - try: - return cursor.fetchall()[0][0] - 1 - except IndexError: - raise error.NodeNotFound() - - def getItemsPublishers(self, itemIdentifiers): - """Get the publishers for all given identifiers - - @return (dict[unicode, jid.JID]): map of itemIdentifiers to publisher - if item is not found, key is skipped in resulting dict - """ - return self.dbpool.runInteraction(self._getItemsPublishers, itemIdentifiers) - - def _getItemsPublishers(self, cursor, itemIdentifiers): - self._checkNodeExists(cursor) - ret = {} - for itemIdentifier in itemIdentifiers: - cursor.execute("""SELECT publisher FROM items - WHERE node_id=%s AND item=%s""", - (self.nodeDbId, itemIdentifier,)) - result = cursor.fetchone() - if result: - ret[itemIdentifier] = jid.JID(result[0]) - return ret - - def purge(self): - return self.dbpool.runInteraction(self._purge) - - def _purge(self, cursor): - self._checkNodeExists(cursor) - - cursor.execute("""DELETE FROM items WHERE - node_id=%s""", - (self.nodeDbId,)) - - -class CollectionNode(Node): - - nodeType = 'collection' - - - -class GatewayStorage(object): - """ - Memory based storage facility for the XMPP-HTTP gateway. - """ - - def __init__(self, dbpool): - self.dbpool = dbpool - - def _countCallbacks(self, cursor, service, nodeIdentifier): - """ - Count number of callbacks registered for a node. - """ - cursor.execute("""SELECT count(*) FROM callbacks - WHERE service=%s and node=%s""", - (service.full(), - nodeIdentifier)) - results = cursor.fetchall() - return results[0][0] - - def addCallback(self, service, nodeIdentifier, callback): - def interaction(cursor): - cursor.execute("""SELECT 1 as bool FROM callbacks - WHERE service=%s and node=%s and uri=%s""", - (service.full(), - nodeIdentifier, - callback)) - if cursor.fetchall(): - return - - cursor.execute("""INSERT INTO callbacks - (service, node, uri) VALUES - (%s, %s, %s)""", - (service.full(), - nodeIdentifier, - callback)) - - return self.dbpool.runInteraction(interaction) - - def removeCallback(self, service, nodeIdentifier, callback): - def interaction(cursor): - cursor.execute("""DELETE FROM callbacks - WHERE service=%s and node=%s and uri=%s""", - (service.full(), - nodeIdentifier, - callback)) - - if cursor.rowcount != 1: - raise error.NotSubscribed() - - last = not self._countCallbacks(cursor, service, nodeIdentifier) - return last - - return self.dbpool.runInteraction(interaction) - - def getCallbacks(self, service, nodeIdentifier): - def interaction(cursor): - cursor.execute("""SELECT uri FROM callbacks - WHERE service=%s and node=%s""", - (service.full(), - nodeIdentifier)) - results = cursor.fetchall() - - if not results: - raise error.NoCallbacks() - - return [result[0] for result in results] - - return self.dbpool.runInteraction(interaction) - - def hasCallbacks(self, service, nodeIdentifier): - def interaction(cursor): - return bool(self._countCallbacks(cursor, service, nodeIdentifier)) - - return self.dbpool.runInteraction(interaction) diff -r 105a0772eedd -r c56a728412f1 src/privilege.py --- a/src/privilege.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,312 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- -# -# Copyright (c) 2015 Jérôme Poisson - - -# 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 . - -# --- - -# This module implements XEP-0356 (Privileged Entity) to manage rosters, messages and presences - -from wokkel import xmppim -from wokkel.compat import IQ -from wokkel import pubsub -from wokkel import disco -from wokkel.iwokkel import IPubSubService -from twisted.python import log -from twisted.python import failure -from twisted.internet import defer -from twisted.words.xish import domish -from twisted.words.protocols.jabber import jid -import time - -FORWARDED_NS = 'urn:xmpp:forward:0' -PRIV_ENT_NS = 'urn:xmpp:privilege:1' -PRIV_ENT_ADV_XPATH = '/message/privilege[@xmlns="{}"]'.format(PRIV_ENT_NS) -ROSTER_NS = 'jabber:iq:roster' -PERM_ROSTER = 'roster' -PERM_MESSAGE = 'message' -PERM_PRESENCE = 'presence' -ALLOWED_ROSTER = ('none', 'get', 'set', 'both') -ALLOWED_MESSAGE = ('none', 'outgoing') -ALLOWED_PRESENCE = ('none', 'managed_entity', 'roster') -TO_CHECK = {PERM_ROSTER:ALLOWED_ROSTER, PERM_MESSAGE:ALLOWED_MESSAGE, PERM_PRESENCE:ALLOWED_PRESENCE} - - -class InvalidStanza(Exception): - pass - -class NotAllowedError(Exception): - pass - -class PrivilegesHandler(disco.DiscoClientProtocol): - #FIXME: need to manage updates, and database sync - #TODO: cache - - def __init__(self, service_jid): - super(PrivilegesHandler, self).__init__() - self._permissions = {PERM_ROSTER: 'none', - PERM_MESSAGE: 'none', - PERM_PRESENCE: 'none'} - self._pubsub_service = None - self._backend = None - # FIXME: we use a hack supposing that our privilege come from hostname - # and we are a component named [name].hostname - # but we need to manage properly server - # TODO: do proper server handling - self.server_jid = jid.JID(service_jid.host.split('.', 1)[1]) - self.caps_map = {} # key: bare jid, value: dict of resources with caps hash - self.hash_map = {} # key: (hash,version), value: dict with DiscoInfo instance (infos) and nodes to notify (notify) - self.roster_cache = {} # key: jid, value: dict with "timestamp" and "roster" - self.presence_map = {} # inverted roster: key: jid, value: set of entities who has this jid in roster (with presence of "from" or "both") - self.server = None - - @property - def permissions(self): - return self._permissions - - def connectionInitialized(self): - for handler in self.parent.handlers: - if IPubSubService.providedBy(handler): - self._pubsub_service = handler - break - self._backend = self.parent.parent.getServiceNamed('backend') - self.xmlstream.addObserver(PRIV_ENT_ADV_XPATH, self.onAdvertise) - self.xmlstream.addObserver('/presence', self.onPresence) - - def onAdvertise(self, message): - """Managage the advertising privileges - - self._permissions will be updated according to advertised privileged - """ - privilege_elt = message.elements(PRIV_ENT_NS, 'privilege').next() - for perm_elt in privilege_elt.elements(PRIV_ENT_NS): - try: - if perm_elt.name != 'perm': - raise InvalidStanza(u'unexpected element {}'.format(perm_elt.name)) - perm_access = perm_elt['access'] - perm_type = perm_elt['type'] - try: - if perm_type not in TO_CHECK[perm_access]: - raise InvalidStanza(u'bad type [{}] for permission {}'.format(perm_type, perm_access)) - except KeyError: - raise InvalidStanza(u'bad permission [{}]'.format(perm_access)) - except InvalidStanza as e: - log.msg("Invalid stanza received ({}), setting permission to none".format(e)) - for perm in self._permissions: - self._permissions[perm] = 'none' - break - - self._permissions[perm_access] = perm_type or 'none' - - log.msg('Privileges updated: roster={roster}, message={message}, presence={presence}'.format(**self._permissions)) - - ## roster ## - - def getRoster(self, to_jid): - """ - Retrieve contact list. - - @return: Roster as a mapping from L{JID} to L{RosterItem}. - @rtype: L{twisted.internet.defer.Deferred} - """ - # TODO: cache results - if self._permissions[PERM_ROSTER] not in ('get', 'both'): - log.msg("WARNING: permission not allowed to get roster") - raise failure.Failure(NotAllowedError('roster get is not allowed')) - - def processRoster(result): - roster = {} - for element in result.query.elements(ROSTER_NS, 'item'): - item = xmppim.RosterItem.fromElement(element) - roster[item.entity] = item - - return roster - - iq = IQ(self.xmlstream, 'get') - iq.addElement((ROSTER_NS, 'query')) - iq["to"] = to_jid.userhost() - d = iq.send() - d.addCallback(processRoster) - return d - - def _isSubscribedFrom(self, roster, entity, roster_owner_jid): - try: - return roster[entity.userhostJID()].subscriptionFrom - except KeyError: - return False - - def isSubscribedFrom(self, entity, roster_owner_jid): - """Check if entity has presence subscription from roster_owner_jid - - @param entity(jid.JID): entity to check subscription to - @param roster_owner_jid(jid.JID): owner of the roster to check - @return D(bool): True if entity has a subscription from roster_owner_jid - """ - d = self.getRoster(roster_owner_jid) - d.addCallback(self._isSubscribedFrom, entity, roster_owner_jid) - return d - - ## message ## - - def sendMessage(self, priv_message, to_jid=None): - """Send privileged message (in the name of the server) - - @param priv_message(domish.Element): privileged message - @param to_jid(jid.JID, None): main message destinee - None to use our own server - """ - if self._permissions[PERM_MESSAGE] not in ('outgoing',): - log.msg("WARNING: permission not allowed to send privileged messages") - raise failure.Failure(NotAllowedError('privileged messages are not allowed')) - - main_message = domish.Element((None, "message")) - if to_jid is None: - to_jid = self.server_jid - main_message['to'] = to_jid.full() - privilege_elt = main_message.addElement((PRIV_ENT_NS, 'privilege')) - forwarded_elt = privilege_elt.addElement((FORWARDED_NS, 'forwarded')) - priv_message['xmlns'] = 'jabber:client' - forwarded_elt.addChild(priv_message) - self.send(main_message) - - def notifyPublish(self, pep_jid, nodeIdentifier, notifications): - """Do notifications using privileges""" - for subscriber, subscriptions, items in notifications: - message = self._pubsub_service._createNotification('items', pep_jid, - nodeIdentifier, subscriber, - subscriptions) - for item in items: - item.uri = pubsub.NS_PUBSUB_EVENT - message.event.items.addChild(item) - self.sendMessage(message) - - - def notifyRetract(self, pep_jid, nodeIdentifier, notifications): - for subscriber, subscriptions, items in notifications: - message = self._pubsub_service._createNotification('items', pep_jid, - nodeIdentifier, subscriber, - subscriptions) - for item in items: - retract = domish.Element((None, "retract")) - retract['id'] = item['id'] - message.event.items.addChild(retract) - self.sendMessage(message) - - - # def notifyDelete(self, service, nodeIdentifier, subscribers, - # redirectURI=None): - # # TODO - # for subscriber in subscribers: - # message = self._createNotification('delete', service, - # nodeIdentifier, - # subscriber) - # if redirectURI: - # redirect = message.event.delete.addElement('redirect') - # redirect['uri'] = redirectURI - # self.send(message) - - - ## presence ## - - @defer.inlineCallbacks - def onPresence(self, presence_elt): - if self.server is None: - # FIXME: we use a hack supposing that our delegation come from hostname - # and we are a component named [name].hostname - # but we need to manage properly allowed servers - # TODO: do proper origin security check - _, self.server = presence_elt['to'].split('.', 1) - from_jid = jid.JID(presence_elt['from']) - from_jid_bare = from_jid.userhostJID() - if from_jid.host == self.server and from_jid_bare not in self.roster_cache: - roster = yield self.getRoster(from_jid_bare) - timestamp = time.time() - self.roster_cache[from_jid_bare] = {'timestamp': timestamp, - 'roster': roster, - } - for roster_jid, roster_item in roster.iteritems(): - if roster_item.subscriptionFrom: - self.presence_map.setdefault(roster_jid, set()).add(from_jid_bare) - - presence_type = presence_elt.getAttribute('type') - if presence_type != "unavailable": - # new resource available, we check entity capabilities - try: - c_elt = next(presence_elt.elements('http://jabber.org/protocol/caps', 'c')) - hash_ = c_elt['hash'] - ver = c_elt['ver'] - except (StopIteration, KeyError): - # no capabilities, we don't go further - return - - # FIXME: hash is not checked (cf. XEP-0115) - disco_tuple = (hash_, ver) - - if disco_tuple not in self.hash_map: - # first time we se this hash, what is behind it? - infos = yield self.requestInfo(from_jid) - self.hash_map[disco_tuple] = { - 'notify': {f[:-7] for f in infos.features if f.endswith('+notify')}, - 'infos': infos - } - - # jid_caps must be filled only after hash_map is set, to be sure that - # the hash data is available in getAutoSubscribers - jid_caps = self.caps_map.setdefault(from_jid_bare, {}) - if from_jid.resource not in jid_caps: - jid_caps[from_jid.resource] = disco_tuple - - # nodes are the nodes subscribed with +notify - nodes = tuple(self.hash_map[disco_tuple]['notify']) - if not nodes: - return - # publishers are entities which have granted presence access to our user + user itself - publishers = tuple(self.presence_map.get(from_jid_bare, ())) + (from_jid_bare,) - - # FIXME: add "presence" access_model (for node) for getLastItems - last_items = yield self._backend.storage.getLastItems(publishers, nodes, ('open',), ('open',), True) - # we send message with last item, as required by https://xmpp.org/extensions/xep-0163.html#notify-last - for pep_jid, node, item, item_access_model in last_items: - self.notifyPublish(pep_jid, node, [(from_jid, None, [item])]) - - ## misc ## - - @defer.inlineCallbacks - def getAutoSubscribers(self, recipient, nodeIdentifier, explicit_subscribers): - """get automatic subscribers, i.e. subscribers with presence subscription and +notify for this node - - @param recipient(jid.JID): jid of the PEP owner of this node - @param nodeIdentifier(unicode): node - @param explicit_subscribers(set(jid.JID}: jids of people which have an explicit subscription - @return (list[jid.JID]): full jid of automatically subscribed entities - """ - auto_subscribers = [] - roster = yield self.getRoster(recipient) - for roster_jid, roster_item in roster.iteritems(): - if roster_jid in explicit_subscribers: - continue - if roster_item.subscriptionFrom: - try: - online_resources = self.caps_map[roster_jid] - except KeyError: - continue - for res, disco_tuple in online_resources.iteritems(): - notify = self.hash_map[disco_tuple]['notify'] - if nodeIdentifier in notify: - full_jid = jid.JID(tuple=(roster_jid.user, roster_jid.host, res)) - auto_subscribers.append(full_jid) - defer.returnValue(auto_subscribers) diff -r 105a0772eedd -r c56a728412f1 src/pubsub_admin.py --- a/src/pubsub_admin.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,135 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2019 Jérôme Poisson -# -# 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 . - -""" -Pubsub Admin experimental protocol implementation - -""" - -from zope.interface import implements -from twisted.python import log -from twisted.internet import defer -from twisted.words.protocols.jabber import jid, error as jabber_error, xmlstream -from sat_pubsub import error -from wokkel.subprotocols import XMPPHandler -from wokkel import disco, iwokkel, pubsub - -NS_PUBSUB_ADMIN = u"https://salut-a-toi.org/spec/pubsub_admin:0" -ADMIN_REQUEST = '/iq[@type="set"]/admin[@xmlns="{}"]'.format(NS_PUBSUB_ADMIN) - - -class PubsubAdminHandler(XMPPHandler): - implements(iwokkel.IDisco) - - def __init__(self, backend): - super(PubsubAdminHandler, self).__init__() - self.backend = backend - - def connectionInitialized(self): - self.xmlstream.addObserver(ADMIN_REQUEST, self.onAdminRequest) - - def sendError(self, iq_elt, condition=u'bad-request'): - stanza_error = jabber_error.StanzaError(condition) - iq_error = stanza_error.toResponse(iq_elt) - self.parent.xmlstream.send(iq_error) - - @defer.inlineCallbacks - def onAdminRequest(self, iq_elt): - """Pubsub Admin request received""" - iq_elt.handled = True - try: - pep = bool(iq_elt.delegated) - except AttributeError: - pep = False - - # is the sender really an admin? - admins = self.backend.config[u'admins_jids_list'] - from_jid = jid.JID(iq_elt[u'from']) - if from_jid.userhostJID() not in admins: - log.msg("WARNING: admin request done by non admin entity {from_jid}" - .format(from_jid=from_jid.full())) - self.sendError(iq_elt, u'forbidden') - return - - # alright, we can proceed - recipient = jid.JID(iq_elt[u'to']) - admin_elt = iq_elt.admin - try: - pubsub_elt = next(admin_elt.elements(pubsub.NS_PUBSUB, u'pubsub')) - publish_elt = next(pubsub_elt.elements(pubsub.NS_PUBSUB, u'publish')) - except StopIteration: - self.sendError(iq_elt) - return - try: - node = publish_elt[u'node'] - except KeyError: - self.sendError(iq_elt) - return - - # we prepare the result IQ request, we will fill it with item ids - iq_result_elt = xmlstream.toResponse(iq_elt, u'result') - result_admin_elt = iq_result_elt.addElement((NS_PUBSUB_ADMIN, u'admin')) - result_pubsub_elt = result_admin_elt.addElement((pubsub.NS_PUBSUB, u'pubsub')) - result_publish_elt = result_pubsub_elt.addElement(u'publish') - result_publish_elt[u'node'] = node - - # now we can send the items - for item in publish_elt.elements(pubsub.NS_PUBSUB, u'item'): - try: - requestor = jid.JID(item.attributes.pop(u'publisher')) - except Exception as e: - log.msg(u"WARNING: invalid jid in publisher ({requestor}): {msg}" - .format(requestor=requestor, msg=e)) - self.sendError(iq_elt) - return - except KeyError: - requestor = from_jid - - # we don't use a DeferredList because we want to be sure that - # each request is done in order - try: - payload = yield self.backend.publish( - nodeIdentifier=node, - items=[item], - requestor=requestor, - pep=pep, - recipient=recipient) - except (error.Forbidden, error.ItemForbidden): - __import__('pudb').set_trace() - self.sendError(iq_elt, u"forbidden") - return - except Exception as e: - self.sendError(iq_elt, u"internal-server-error") - log.msg(u"INTERNAL ERROR: {msg}".format(msg=e)) - return - - result_item_elt = result_publish_elt.addElement(u'item') - # either the id was given and it is available in item - # either it's a new item, and we can retrieve it from return payload - try: - result_item_elt[u'id'] = item[u'id'] - except KeyError: - result_item_elt = payload.publish.item[u'id'] - - self.xmlstream.send(iq_result_elt) - - def getDiscoInfo(self, requestor, service, nodeIdentifier=''): - return [disco.DiscoFeature(NS_PUBSUB_ADMIN)] - - def getDiscoItems(self, requestor, service, nodeIdentifier=''): - return [] diff -r 105a0772eedd -r c56a728412f1 src/schema.py --- a/src/schema.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,104 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- -# -# Copyright (c) 2015 Jérôme Poisson - - -# 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 . - -# --- - -# This module implements node schema - -from twisted.words.protocols.jabber import jid -from twisted.words.xish import domish -from wokkel import disco, iwokkel -from wokkel.iwokkel import IPubSubService -from wokkel.subprotocols import XMPPHandler, IQHandlerMixin -from wokkel import data_form, pubsub -from zope.interface import implements -from sat_pubsub import const - -QUERY_SCHEMA = "/pubsub[@xmlns='" + const.NS_SCHEMA + "']" - - -class SchemaHandler(XMPPHandler, IQHandlerMixin): - implements(iwokkel.IDisco) - iqHandlers = {"/iq[@type='get']" + QUERY_SCHEMA: 'onSchemaGet', - "/iq[@type='set']" + QUERY_SCHEMA: 'onSchemaSet'} - - def __init__(self): - super(SchemaHandler, self).__init__() - self.pubsub_service = None - - def connectionInitialized(self): - for handler in self.parent.handlers: - if IPubSubService.providedBy(handler): - self.pubsub_service = handler - break - self.backend = self.parent.parent.getServiceNamed('backend') - self.xmlstream.addObserver("/iq[@type='get' or @type='set']" + QUERY_SCHEMA, self.handleRequest) - - def _getNodeSchemaCb(self, x_elt, nodeIdentifier): - schema_elt = domish.Element((const.NS_SCHEMA, 'schema')) - schema_elt['node'] = nodeIdentifier - if x_elt is not None: - assert x_elt.uri == u'jabber:x:data' - schema_elt.addChild(x_elt) - return schema_elt - - def onSchemaGet(self, iq_elt): - try: - schema_elt = next(iq_elt.pubsub.elements(const.NS_SCHEMA, 'schema')) - nodeIdentifier = schema_elt['node'] - except StopIteration: - raise pubsub.BadRequest(text='missing schema element') - except KeyError: - raise pubsub.BadRequest(text='missing node') - pep = iq_elt.delegated - recipient = jid.JID(iq_elt['to']) - d = self.backend.getNodeSchema(nodeIdentifier, - pep, - recipient) - d.addCallback(self._getNodeSchemaCb, nodeIdentifier) - return d.addErrback(self.pubsub_service.resource._mapErrors) - - def onSchemaSet(self, iq_elt): - try: - schema_elt = next(iq_elt.pubsub.elements(const.NS_SCHEMA, 'schema')) - nodeIdentifier = schema_elt['node'] - except StopIteration: - raise pubsub.BadRequest(text='missing schema element') - except KeyError: - raise pubsub.BadRequest(text='missing node') - requestor = jid.JID(iq_elt['from']) - pep = iq_elt.delegated - recipient = jid.JID(iq_elt['to']) - try: - x_elt = next(schema_elt.elements(data_form.NS_X_DATA, u'x')) - except StopIteration: - # no schema form has been found - x_elt = None - d = self.backend.setNodeSchema(nodeIdentifier, - x_elt, - requestor, - pep, - recipient) - return d.addErrback(self.pubsub_service.resource._mapErrors) - - def getDiscoInfo(self, requestor, service, nodeIdentifier=''): - return [disco.DiscoFeature(const.NS_SCHEMA)] - - def getDiscoItems(self, requestor, service, nodeIdentifier=''): - return [] diff -r 105a0772eedd -r c56a728412f1 src/test/__init__.py --- a/src/test/__init__.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,55 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2019 Jérôme Poisson - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -""" -Tests for L{idavoll}. -""" diff -r 105a0772eedd -r c56a728412f1 src/test/test_backend.py --- a/src/test/test_backend.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,692 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2019 Jérôme Poisson - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -""" -Tests for L{idavoll.backend}. -""" - -from zope.interface import implements -from zope.interface.verify import verifyObject - -from twisted.internet import defer -from twisted.trial import unittest -from twisted.words.protocols.jabber import jid -from twisted.words.protocols.jabber.error import StanzaError - -from wokkel import iwokkel, pubsub - -from sat_pubsub import backend, error, iidavoll, const - -OWNER = jid.JID('owner@example.com') -OWNER_FULL = jid.JID('owner@example.com/home') -SERVICE = jid.JID('test.example.org') -NS_PUBSUB = 'http://jabber.org/protocol/pubsub' - -class BackendTest(unittest.TestCase): - - def test_interfaceIBackend(self): - self.assertTrue(verifyObject(iidavoll.IBackendService, - backend.BackendService(None))) - - - def test_deleteNode(self): - class TestNode: - nodeIdentifier = 'to-be-deleted' - def getAffiliation(self, entity): - if entity.userhostJID() == OWNER: - return defer.succeed('owner') - - class TestStorage: - def __init__(self): - self.deleteCalled = [] - - def getNode(self, nodeIdentifier): - return defer.succeed(TestNode()) - - def deleteNode(self, nodeIdentifier): - if nodeIdentifier in ['to-be-deleted']: - self.deleteCalled.append(nodeIdentifier) - return defer.succeed(None) - else: - return defer.fail(error.NodeNotFound()) - - def preDelete(data): - self.assertFalse(self.storage.deleteCalled) - preDeleteCalled.append(data) - return defer.succeed(None) - - def cb(result): - self.assertEquals(1, len(preDeleteCalled)) - data = preDeleteCalled[-1] - self.assertEquals('to-be-deleted', data['node'].nodeIdentifier) - self.assertTrue(self.storage.deleteCalled) - - self.storage = TestStorage() - self.backend = backend.BackendService(self.storage) - - preDeleteCalled = [] - - self.backend.registerPreDelete(preDelete) - d = self.backend.deleteNode('to-be-deleted', OWNER_FULL) - d.addCallback(cb) - return d - - - def test_deleteNodeRedirect(self): - uri = 'xmpp:%s?;node=test2' % (SERVICE.full(),) - - class TestNode: - nodeIdentifier = 'to-be-deleted' - def getAffiliation(self, entity): - if entity.userhostJID() == OWNER: - return defer.succeed('owner') - - class TestStorage: - def __init__(self): - self.deleteCalled = [] - - def getNode(self, nodeIdentifier): - return defer.succeed(TestNode()) - - def deleteNode(self, nodeIdentifier): - if nodeIdentifier in ['to-be-deleted']: - self.deleteCalled.append(nodeIdentifier) - return defer.succeed(None) - else: - return defer.fail(error.NodeNotFound()) - - def preDelete(data): - self.assertFalse(self.storage.deleteCalled) - preDeleteCalled.append(data) - return defer.succeed(None) - - def cb(result): - self.assertEquals(1, len(preDeleteCalled)) - data = preDeleteCalled[-1] - self.assertEquals('to-be-deleted', data['node'].nodeIdentifier) - self.assertEquals(uri, data['redirectURI']) - self.assertTrue(self.storage.deleteCalled) - - self.storage = TestStorage() - self.backend = backend.BackendService(self.storage) - - preDeleteCalled = [] - - self.backend.registerPreDelete(preDelete) - d = self.backend.deleteNode('to-be-deleted', OWNER, redirectURI=uri) - d.addCallback(cb) - return d - - - def test_createNodeNoID(self): - """ - Test creation of a node without a given node identifier. - """ - class TestStorage: - def getDefaultConfiguration(self, nodeType): - return {} - - def createNode(self, nodeIdentifier, requestor, config): - self.nodeIdentifier = nodeIdentifier - return defer.succeed(None) - - self.storage = TestStorage() - self.backend = backend.BackendService(self.storage) - self.storage.backend = self.backend - - def checkID(nodeIdentifier): - self.assertNotIdentical(None, nodeIdentifier) - self.assertIdentical(self.storage.nodeIdentifier, nodeIdentifier) - - d = self.backend.createNode(None, OWNER_FULL) - d.addCallback(checkID) - return d - - class NodeStore: - """ - I just store nodes to pose as an L{IStorage} implementation. - """ - def __init__(self, nodes): - self.nodes = nodes - - def getNode(self, nodeIdentifier): - try: - return defer.succeed(self.nodes[nodeIdentifier]) - except KeyError: - return defer.fail(error.NodeNotFound()) - - - def test_getNotifications(self): - """ - Ensure subscribers show up in the notification list. - """ - item = pubsub.Item() - sub = pubsub.Subscription('test', OWNER, 'subscribed') - - class TestNode: - def getSubscriptions(self, state=None): - return [sub] - - def cb(result): - self.assertEquals(1, len(result)) - subscriber, subscriptions, items = result[-1] - - self.assertEquals(OWNER, subscriber) - self.assertEquals({sub}, subscriptions) - self.assertEquals([item], items) - - self.storage = self.NodeStore({'test': TestNode()}) - self.backend = backend.BackendService(self.storage) - d = self.backend.getNotifications('test', [item]) - d.addCallback(cb) - return d - - def test_getNotificationsRoot(self): - """ - Ensure subscribers to the root node show up in the notification list - for leaf nodes. - - This assumes a flat node relationship model with exactly one collection - node: the root node. Each leaf node is automatically a child node - of the root node. - """ - item = pubsub.Item() - subRoot = pubsub.Subscription('', OWNER, 'subscribed') - - class TestNode: - def getSubscriptions(self, state=None): - return [] - - class TestRootNode: - def getSubscriptions(self, state=None): - return [subRoot] - - def cb(result): - self.assertEquals(1, len(result)) - subscriber, subscriptions, items = result[-1] - self.assertEquals(OWNER, subscriber) - self.assertEquals({subRoot}, subscriptions) - self.assertEquals([item], items) - - self.storage = self.NodeStore({'test': TestNode(), - '': TestRootNode()}) - self.backend = backend.BackendService(self.storage) - d = self.backend.getNotifications('test', [item]) - d.addCallback(cb) - return d - - - def test_getNotificationsMultipleNodes(self): - """ - Ensure that entities that subscribe to a leaf node as well as the - root node get exactly one notification. - """ - item = pubsub.Item() - sub = pubsub.Subscription('test', OWNER, 'subscribed') - subRoot = pubsub.Subscription('', OWNER, 'subscribed') - - class TestNode: - def getSubscriptions(self, state=None): - return [sub] - - class TestRootNode: - def getSubscriptions(self, state=None): - return [subRoot] - - def cb(result): - self.assertEquals(1, len(result)) - subscriber, subscriptions, items = result[-1] - - self.assertEquals(OWNER, subscriber) - self.assertEquals({sub, subRoot}, subscriptions) - self.assertEquals([item], items) - - self.storage = self.NodeStore({'test': TestNode(), - '': TestRootNode()}) - self.backend = backend.BackendService(self.storage) - d = self.backend.getNotifications('test', [item]) - d.addCallback(cb) - return d - - - def test_getDefaultConfiguration(self): - """ - L{backend.BackendService.getDefaultConfiguration} should return - a deferred that fires a dictionary with configuration values. - """ - - class TestStorage: - def getDefaultConfiguration(self, nodeType): - return { - "pubsub#persist_items": True, - "pubsub#deliver_payloads": True} - - def cb(options): - self.assertIn("pubsub#persist_items", options) - self.assertEqual(True, options["pubsub#persist_items"]) - - self.backend = backend.BackendService(TestStorage()) - d = self.backend.getDefaultConfiguration('leaf') - d.addCallback(cb) - return d - - - def test_getNodeConfiguration(self): - class testNode: - nodeIdentifier = 'node' - def getConfiguration(self): - return {'pubsub#deliver_payloads': True, - 'pubsub#persist_items': False} - - class testStorage: - def getNode(self, nodeIdentifier): - return defer.succeed(testNode()) - - def cb(options): - self.assertIn("pubsub#deliver_payloads", options) - self.assertEqual(True, options["pubsub#deliver_payloads"]) - self.assertIn("pubsub#persist_items", options) - self.assertEqual(False, options["pubsub#persist_items"]) - - self.storage = testStorage() - self.backend = backend.BackendService(self.storage) - self.storage.backend = self.backend - - d = self.backend.getNodeConfiguration('node') - d.addCallback(cb) - return d - - - def test_setNodeConfiguration(self): - class testNode: - nodeIdentifier = 'node' - def getAffiliation(self, entity): - if entity.userhostJID() == OWNER: - return defer.succeed('owner') - def setConfiguration(self, options): - self.options = options - - class testStorage: - def __init__(self): - self.nodes = {'node': testNode()} - def getNode(self, nodeIdentifier): - return defer.succeed(self.nodes[nodeIdentifier]) - - def checkOptions(node): - options = node.options - self.assertIn("pubsub#deliver_payloads", options) - self.assertEqual(True, options["pubsub#deliver_payloads"]) - self.assertIn("pubsub#persist_items", options) - self.assertEqual(False, options["pubsub#persist_items"]) - - def cb(result): - d = self.storage.getNode('node') - d.addCallback(checkOptions) - return d - - self.storage = testStorage() - self.backend = backend.BackendService(self.storage) - self.storage.backend = self.backend - - options = {'pubsub#deliver_payloads': True, - 'pubsub#persist_items': False} - - d = self.backend.setNodeConfiguration('node', options, OWNER_FULL) - d.addCallback(cb) - return d - - - def test_publishNoID(self): - """ - Test publish request with an item without a node identifier. - """ - class TestNode: - nodeType = 'leaf' - nodeIdentifier = 'node' - def getAffiliation(self, entity): - if entity.userhostJID() == OWNER: - return defer.succeed('owner') - def getConfiguration(self): - return {'pubsub#deliver_payloads': True, - 'pubsub#persist_items': False, - const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_OPEN} - - class TestStorage: - def getNode(self, nodeIdentifier): - return defer.succeed(TestNode()) - - def checkID(notification): - self.assertNotIdentical(None, notification['items'][0][2]['id']) - - self.storage = TestStorage() - self.backend = backend.BackendService(self.storage) - self.storage.backend = self.backend - - self.backend.registerNotifier(checkID) - - items = [pubsub.Item()] - d = self.backend.publish('node', items, OWNER_FULL) - return d - - - def test_notifyOnSubscription(self): - """ - Test notification of last published item on subscription. - """ - ITEM = "" % NS_PUBSUB - - class TestNode: - implements(iidavoll.ILeafNode) - nodeIdentifier = 'node' - nodeType = 'leaf' - def getAffiliation(self, entity): - if entity is OWNER: - return defer.succeed('owner') - def getConfiguration(self): - return {'pubsub#deliver_payloads': True, - 'pubsub#persist_items': False, - 'pubsub#send_last_published_item': 'on_sub', - const.OPT_ACCESS_MODEL: const.VAL_AMODEL_OPEN} - def getItems(self, authorized_groups, unrestricted, maxItems): - return defer.succeed([(ITEM, const.VAL_AMODEL_OPEN, None)]) - def addSubscription(self, subscriber, state, options): - self.subscription = pubsub.Subscription('node', subscriber, - state, options) - return defer.succeed(None) - def getSubscription(self, subscriber): - return defer.succeed(self.subscription) - def getNodeOwner(self): - return defer.succeed(OWNER) - - class TestStorage: - def getNode(self, nodeIdentifier): - return defer.succeed(TestNode()) - - def cb(data): - self.assertEquals('node', data['node'].nodeIdentifier) - self.assertEquals([ITEM], data['items']) - self.assertEquals(OWNER, data['subscription'].subscriber) - - self.storage = TestStorage() - self.backend = backend.BackendService(self.storage) - self.storage.backend = self.backend - - class Roster(object): - def getRoster(self, owner): - return {} - self.backend.roster = Roster() - - d1 = defer.Deferred() - d1.addCallback(cb) - self.backend.registerNotifier(d1.callback) - d2 = self.backend.subscribe('node', OWNER, OWNER_FULL) - return defer.gatherResults([d1, d2]) - - test_notifyOnSubscription.timeout = 2 - - - -class BaseTestBackend(object): - """ - Base class for backend stubs. - """ - - def supportsPublisherAffiliation(self): - return True - - - def supportsOutcastAffiliation(self): - return True - - - def supportsPersistentItems(self): - return True - - - def supportsInstantNodes(self): - return True - - def supportsItemAccess(self): - return True - - def supportsAutoCreate(self): - return True - - def supportsCreatorCheck(self): - return True - - def supportsGroupBlog(self): - return True - - def registerNotifier(self, observerfn, *args, **kwargs): - return - - - def registerPreDelete(self, preDeleteFn): - return - - - -class PubSubResourceFromBackendTest(unittest.TestCase): - - def test_interface(self): - resource = backend.PubSubResourceFromBackend(BaseTestBackend()) - self.assertTrue(verifyObject(iwokkel.IPubSubResource, resource)) - - - def test_preDelete(self): - """ - Test pre-delete sending out notifications to subscribers. - """ - - class TestBackend(BaseTestBackend): - preDeleteFn = None - - def registerPreDelete(self, preDeleteFn): - self.preDeleteFn = preDeleteFn - - def getSubscribers(self, nodeIdentifier): - return defer.succeed([OWNER]) - - def notifyDelete(service, nodeIdentifier, subscribers, - redirectURI=None): - self.assertEqual(SERVICE, service) - self.assertEqual('test', nodeIdentifier) - self.assertEqual([OWNER], subscribers) - self.assertIdentical(None, redirectURI) - d1.callback(None) - - d1 = defer.Deferred() - resource = backend.PubSubResourceFromBackend(TestBackend()) - resource.serviceJID = SERVICE - resource.pubsubService = pubsub.PubSubService() - resource.pubsubService.notifyDelete = notifyDelete - self.assertTrue(verifyObject(iwokkel.IPubSubResource, resource)) - self.assertNotIdentical(None, resource.backend.preDeleteFn) - - class TestNode: - implements(iidavoll.ILeafNode) - nodeIdentifier = 'test' - nodeType = 'leaf' - - data = {'node': TestNode()} - d2 = resource.backend.preDeleteFn(data) - return defer.DeferredList([d1, d2], fireOnOneErrback=1) - - - def test_preDeleteRedirect(self): - """ - Test pre-delete sending out notifications to subscribers. - """ - - uri = 'xmpp:%s?;node=test2' % (SERVICE.full(),) - - class TestBackend(BaseTestBackend): - preDeleteFn = None - - def registerPreDelete(self, preDeleteFn): - self.preDeleteFn = preDeleteFn - - def getSubscribers(self, nodeIdentifier): - return defer.succeed([OWNER]) - - def notifyDelete(service, nodeIdentifier, subscribers, - redirectURI=None): - self.assertEqual(SERVICE, service) - self.assertEqual('test', nodeIdentifier) - self.assertEqual([OWNER], subscribers) - self.assertEqual(uri, redirectURI) - d1.callback(None) - - d1 = defer.Deferred() - resource = backend.PubSubResourceFromBackend(TestBackend()) - resource.serviceJID = SERVICE - resource.pubsubService = pubsub.PubSubService() - resource.pubsubService.notifyDelete = notifyDelete - self.assertTrue(verifyObject(iwokkel.IPubSubResource, resource)) - self.assertNotIdentical(None, resource.backend.preDeleteFn) - - class TestNode: - implements(iidavoll.ILeafNode) - nodeIdentifier = 'test' - nodeType = 'leaf' - - data = {'node': TestNode(), - 'redirectURI': uri} - d2 = resource.backend.preDeleteFn(data) - return defer.DeferredList([d1, d2], fireOnOneErrback=1) - - - def test_unsubscribeNotSubscribed(self): - """ - Test unsubscription request when not subscribed. - """ - - class TestBackend(BaseTestBackend): - def unsubscribe(self, nodeIdentifier, subscriber, requestor): - return defer.fail(error.NotSubscribed()) - - def cb(e): - self.assertEquals('unexpected-request', e.condition) - - resource = backend.PubSubResourceFromBackend(TestBackend()) - request = pubsub.PubSubRequest() - request.sender = OWNER - request.recipient = SERVICE - request.nodeIdentifier = 'test' - request.subscriber = OWNER - d = resource.unsubscribe(request) - self.assertFailure(d, StanzaError) - d.addCallback(cb) - return d - - - def test_getInfo(self): - """ - Test retrieving node information. - """ - - class TestBackend(BaseTestBackend): - def getNodeType(self, nodeIdentifier): - return defer.succeed('leaf') - - def getNodeMetaData(self, nodeIdentifier): - return defer.succeed({'pubsub#persist_items': True}) - - def cb(info): - self.assertIn('type', info) - self.assertEquals('leaf', info['type']) - self.assertIn('meta-data', info) - self.assertEquals({'pubsub#persist_items': True}, info['meta-data']) - - resource = backend.PubSubResourceFromBackend(TestBackend()) - d = resource.getInfo(OWNER, SERVICE, 'test') - d.addCallback(cb) - return d - - - def test_getConfigurationOptions(self): - class TestBackend(BaseTestBackend): - nodeOptions = { - "pubsub#persist_items": - {"type": "boolean", - "label": "Persist items to storage"}, - "pubsub#deliver_payloads": - {"type": "boolean", - "label": "Deliver payloads with event notifications"} - } - - resource = backend.PubSubResourceFromBackend(TestBackend()) - options = resource.getConfigurationOptions() - self.assertIn("pubsub#persist_items", options) - - - def test_default(self): - class TestBackend(BaseTestBackend): - def getDefaultConfiguration(self, nodeType): - options = {"pubsub#persist_items": True, - "pubsub#deliver_payloads": True, - "pubsub#send_last_published_item": 'on_sub', - } - return defer.succeed(options) - - def cb(options): - self.assertEquals(True, options["pubsub#persist_items"]) - - resource = backend.PubSubResourceFromBackend(TestBackend()) - request = pubsub.PubSubRequest() - request.sender = OWNER - request.recipient = SERVICE - request.nodeType = 'leaf' - d = resource.default(request) - d.addCallback(cb) - return d diff -r 105a0772eedd -r c56a728412f1 src/test/test_gateway.py --- a/src/test/test_gateway.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,822 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2019 Jérôme Poisson - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -""" -Tests for L{idavoll.gateway}. - -Note that some tests are functional tests that require a running idavoll -service. -""" - -from StringIO import StringIO - -import simplejson - -from twisted.internet import defer -from twisted.trial import unittest -from twisted.web import error, http, http_headers, server -from twisted.web.test import requesthelper -from twisted.words.xish import domish -from twisted.words.protocols.jabber.jid import JID - -from sat_pubsub import gateway -from sat_pubsub.backend import BackendService -from sat_pubsub.memory_storage import Storage - -AGENT = "Idavoll Test Script" -NS_ATOM = "http://www.w3.org/2005/Atom" - -TEST_ENTRY = domish.Element((NS_ATOM, 'entry')) -TEST_ENTRY.addElement("id", - content="urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a") -TEST_ENTRY.addElement("title", content="Atom-Powered Robots Run Amok") -TEST_ENTRY.addElement("author").addElement("name", content="John Doe") -TEST_ENTRY.addElement("content", content="Some text.") - -baseURI = "http://localhost:8086/" -component = "pubsub" -componentJID = JID(component) -ownerJID = JID('owner@example.org') - -def _render(resource, request): - result = resource.render(request) - if isinstance(result, str): - request.write(result) - request.finish() - return defer.succeed(None) - elif result is server.NOT_DONE_YET: - if request.finished: - return defer.succeed(None) - else: - return request.notifyFinish() - else: - raise ValueError("Unexpected return value: %r" % (result,)) - - -class DummyRequest(requesthelper.DummyRequest): - - def __init__(self, *args, **kwargs): - requesthelper.DummyRequest.__init__(self, *args, **kwargs) - self.requestHeaders = http_headers.Headers() - - - -class GetServiceAndNodeTest(unittest.TestCase): - """ - Tests for {gateway.getServiceAndNode}. - """ - - def test_basic(self): - """ - getServiceAndNode parses an XMPP URI with node parameter. - """ - uri = b'xmpp:pubsub.example.org?;node=test' - service, nodeIdentifier = gateway.getServiceAndNode(uri) - self.assertEqual(JID(u'pubsub.example.org'), service) - self.assertEqual(u'test', nodeIdentifier) - - - def test_schemeEmpty(self): - """ - If the URI scheme is empty, an exception is raised. - """ - uri = b'pubsub.example.org' - self.assertRaises(gateway.XMPPURIParseError, - gateway.getServiceAndNode, uri) - - - def test_schemeNotXMPP(self): - """ - If the URI scheme is not 'xmpp', an exception is raised. - """ - uri = b'mailto:test@example.org' - self.assertRaises(gateway.XMPPURIParseError, - gateway.getServiceAndNode, uri) - - - def test_authorityPresent(self): - """ - If the URI has an authority component, an exception is raised. - """ - uri = b'xmpp://pubsub.example.org/' - self.assertRaises(gateway.XMPPURIParseError, - gateway.getServiceAndNode, uri) - - - def test_queryEmpty(self): - """ - If there is no query component, the nodeIdentifier is empty. - """ - uri = b'xmpp:pubsub.example.org' - service, nodeIdentifier = gateway.getServiceAndNode(uri) - - self.assertEqual(JID(u'pubsub.example.org'), service) - self.assertEqual(u'', nodeIdentifier) - - - def test_jidInvalid(self): - """ - If the JID from the path component is invalid, an exception is raised. - """ - uri = b'xmpp:@@pubsub.example.org?;node=test' - self.assertRaises(gateway.XMPPURIParseError, - gateway.getServiceAndNode, uri) - - - def test_pathEmpty(self): - """ - If there is no path component, an exception is raised. - """ - uri = b'xmpp:?node=test' - self.assertRaises(gateway.XMPPURIParseError, - gateway.getServiceAndNode, uri) - - - def test_nodeAbsent(self): - """ - If the node parameter is missing, the nodeIdentifier is empty. - """ - uri = b'xmpp:pubsub.example.org?' - service, nodeIdentifier = gateway.getServiceAndNode(uri) - - self.assertEqual(JID(u'pubsub.example.org'), service) - self.assertEqual(u'', nodeIdentifier) - - - -class GetXMPPURITest(unittest.TestCase): - """ - Tests for L{gateway.getXMPPURITest}. - """ - - def test_basic(self): - uri = gateway.getXMPPURI(JID(u'pubsub.example.org'), u'test') - self.assertEqual('xmpp:pubsub.example.org?;node=test', uri) - - -class CreateResourceTest(unittest.TestCase): - """ - Tests for L{gateway.CreateResource}. - """ - - def setUp(self): - self.backend = BackendService(Storage()) - self.resource = gateway.CreateResource(self.backend, componentJID, - ownerJID) - - - def test_get(self): - """ - The method GET is not supported. - """ - request = DummyRequest([b'']) - self.assertRaises(error.UnsupportedMethod, - _render, self.resource, request) - - - def test_post(self): - """ - Upon a POST, a new node is created and the URI returned. - """ - request = DummyRequest([b'']) - request.method = 'POST' - - def gotNodes(nodeIdentifiers, uri): - service, nodeIdentifier = gateway.getServiceAndNode(uri) - self.assertIn(nodeIdentifier, nodeIdentifiers) - - def rendered(result): - self.assertEqual('application/json', - request.outgoingHeaders['content-type']) - payload = simplejson.loads(b''.join(request.written)) - self.assertIn('uri', payload) - d = self.backend.getNodes() - d.addCallback(gotNodes, payload['uri']) - return d - - d = _render(self.resource, request) - d.addCallback(rendered) - return d - - - -class DeleteResourceTest(unittest.TestCase): - """ - Tests for L{gateway.DeleteResource}. - """ - - def setUp(self): - self.backend = BackendService(Storage()) - self.resource = gateway.DeleteResource(self.backend, componentJID, - ownerJID) - - - def test_get(self): - """ - The method GET is not supported. - """ - request = DummyRequest([b'']) - self.assertRaises(error.UnsupportedMethod, - _render, self.resource, request) - - - def test_post(self): - """ - Upon a POST, a new node is created and the URI returned. - """ - request = DummyRequest([b'']) - request.method = b'POST' - - def rendered(result): - self.assertEqual(http.NO_CONTENT, request.responseCode) - - def nodeCreated(nodeIdentifier): - uri = gateway.getXMPPURI(componentJID, nodeIdentifier) - request.args[b'uri'] = [uri] - request.content = StringIO(b'') - - return _render(self.resource, request) - - d = self.backend.createNode(u'test', ownerJID) - d.addCallback(nodeCreated) - d.addCallback(rendered) - return d - - - def test_postWithRedirect(self): - """ - Upon a POST, a new node is created and the URI returned. - """ - request = DummyRequest([b'']) - request.method = b'POST' - otherNodeURI = b'xmpp:pubsub.example.org?node=other' - - def rendered(result): - self.assertEqual(http.NO_CONTENT, request.responseCode) - self.assertEqual(1, len(deletes)) - nodeIdentifier, owner, redirectURI = deletes[-1] - self.assertEqual(otherNodeURI, redirectURI) - - def nodeCreated(nodeIdentifier): - uri = gateway.getXMPPURI(componentJID, nodeIdentifier) - request.args[b'uri'] = [uri] - payload = {b'redirect_uri': otherNodeURI} - body = simplejson.dumps(payload) - request.content = StringIO(body) - return _render(self.resource, request) - - def deleteNode(nodeIdentifier, owner, redirectURI): - deletes.append((nodeIdentifier, owner, redirectURI)) - return defer.succeed(nodeIdentifier) - - deletes = [] - self.patch(self.backend, 'deleteNode', deleteNode) - d = self.backend.createNode(u'test', ownerJID) - d.addCallback(nodeCreated) - d.addCallback(rendered) - return d - - - def test_postUnknownNode(self): - """ - If the node to be deleted is unknown, 404 Not Found is returned. - """ - request = DummyRequest([b'']) - request.method = b'POST' - - def rendered(result): - self.assertEqual(http.NOT_FOUND, request.responseCode) - - uri = gateway.getXMPPURI(componentJID, u'unknown') - request.args[b'uri'] = [uri] - request.content = StringIO(b'') - - d = _render(self.resource, request) - d.addCallback(rendered) - return d - - - def test_postMalformedXMPPURI(self): - """ - If the XMPP URI is malformed, Bad Request is returned. - """ - request = DummyRequest([b'']) - request.method = b'POST' - - def rendered(result): - self.assertEqual(http.BAD_REQUEST, request.responseCode) - - uri = 'xmpp:@@@@' - request.args[b'uri'] = [uri] - request.content = StringIO(b'') - - d = _render(self.resource, request) - d.addCallback(rendered) - return d - - - def test_postURIMissing(self): - """ - If no URI is passed, 400 Bad Request is returned. - """ - request = DummyRequest([b'']) - request.method = b'POST' - - def rendered(result): - self.assertEqual(http.BAD_REQUEST, request.responseCode) - - request.content = StringIO(b'') - - d = _render(self.resource, request) - d.addCallback(rendered) - return d - - - -class CallbackResourceTest(unittest.TestCase): - """ - Tests for L{gateway.CallbackResource}. - """ - - def setUp(self): - self.callbackEvents = [] - self.resource = gateway.CallbackResource(self._callback) - - - def _callback(self, payload, headers): - self.callbackEvents.append((payload, headers)) - - - def test_get(self): - """ - The method GET is not supported. - """ - request = DummyRequest([b'']) - self.assertRaises(error.UnsupportedMethod, - _render, self.resource, request) - - - def test_post(self): - """ - The body posted is passed to the callback. - """ - request = DummyRequest([b'']) - request.method = 'POST' - request.content = StringIO(b'') - - def rendered(result): - self.assertEqual(1, len(self.callbackEvents)) - payload, headers = self.callbackEvents[-1] - self.assertEqual('root', payload.name) - - self.assertEqual(http.NO_CONTENT, request.responseCode) - self.assertFalse(b''.join(request.written)) - - d = _render(self.resource, request) - d.addCallback(rendered) - return d - - - def test_postEvent(self): - """ - If the Event header is set, the payload is empty and the header passed. - """ - request = DummyRequest([b'']) - request.method = 'POST' - request.requestHeaders.addRawHeader(b'Event', b'DELETE') - request.content = StringIO(b'') - - def rendered(result): - self.assertEqual(1, len(self.callbackEvents)) - payload, headers = self.callbackEvents[-1] - self.assertIdentical(None, payload) - self.assertEqual(['DELETE'], headers.getRawHeaders(b'Event')) - self.assertFalse(b''.join(request.written)) - - d = _render(self.resource, request) - d.addCallback(rendered) - return d - - - -class GatewayTest(unittest.TestCase): - timeout = 2 - - def setUp(self): - self.client = gateway.GatewayClient(baseURI) - self.client.startService() - self.addCleanup(self.client.stopService) - - def trapConnectionRefused(failure): - from twisted.internet.error import ConnectionRefusedError - failure.trap(ConnectionRefusedError) - raise unittest.SkipTest("Gateway to test against is not available") - - def trapNotFound(failure): - from twisted.web.error import Error - failure.trap(Error) - - d = self.client.ping() - d.addErrback(trapConnectionRefused) - d.addErrback(trapNotFound) - return d - - - def tearDown(self): - return self.client.stopService() - - - def test_create(self): - - def cb(response): - self.assertIn('uri', response) - - d = self.client.create() - d.addCallback(cb) - return d - - def test_publish(self): - - def cb(response): - self.assertIn('uri', response) - - d = self.client.publish(TEST_ENTRY) - d.addCallback(cb) - return d - - def test_publishExistingNode(self): - - def cb2(response, xmppURI): - self.assertEquals(xmppURI, response['uri']) - - def cb1(response): - xmppURI = response['uri'] - d = self.client.publish(TEST_ENTRY, xmppURI) - d.addCallback(cb2, xmppURI) - return d - - d = self.client.create() - d.addCallback(cb1) - return d - - def test_publishNonExisting(self): - def cb(err): - self.assertEqual('404', err.status) - - d = self.client.publish(TEST_ENTRY, 'xmpp:%s?node=test' % component) - self.assertFailure(d, error.Error) - d.addCallback(cb) - return d - - def test_delete(self): - def cb(response): - xmppURI = response['uri'] - d = self.client.delete(xmppURI) - return d - - d = self.client.create() - d.addCallback(cb) - return d - - def test_deleteWithRedirect(self): - def cb(response): - xmppURI = response['uri'] - redirectURI = 'xmpp:%s?node=test' % component - d = self.client.delete(xmppURI, redirectURI) - return d - - d = self.client.create() - d.addCallback(cb) - return d - - def test_deleteNotification(self): - def onNotification(data, headers): - try: - self.assertTrue(headers.hasHeader('Event')) - self.assertEquals(['DELETED'], headers.getRawHeaders('Event')) - self.assertFalse(headers.hasHeader('Link')) - except: - self.client.deferred.errback() - else: - self.client.deferred.callback(None) - - def cb(response): - xmppURI = response['uri'] - d = self.client.subscribe(xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb2(xmppURI): - d = self.client.delete(xmppURI) - return d - - self.client.callback = onNotification - self.client.deferred = defer.Deferred() - d = self.client.create() - d.addCallback(cb) - d.addCallback(cb2) - return defer.gatherResults([d, self.client.deferred]) - - def test_deleteNotificationWithRedirect(self): - redirectURI = 'xmpp:%s?node=test' % component - - def onNotification(data, headers): - try: - self.assertTrue(headers.hasHeader('Event')) - self.assertEquals(['DELETED'], headers.getRawHeaders('Event')) - self.assertEquals(['<%s>; rel=alternate' % redirectURI], - headers.getRawHeaders('Link')) - except: - self.client.deferred.errback() - else: - self.client.deferred.callback(None) - - def cb(response): - xmppURI = response['uri'] - d = self.client.subscribe(xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb2(xmppURI): - d = self.client.delete(xmppURI, redirectURI) - return d - - self.client.callback = onNotification - self.client.deferred = defer.Deferred() - d = self.client.create() - d.addCallback(cb) - d.addCallback(cb2) - return defer.gatherResults([d, self.client.deferred]) - - def test_list(self): - d = self.client.listNodes() - return d - - def test_subscribe(self): - def cb(response): - xmppURI = response['uri'] - d = self.client.subscribe(xmppURI) - return d - - d = self.client.create() - d.addCallback(cb) - return d - - def test_subscribeGetNotification(self): - - def onNotification(data, headers): - self.client.deferred.callback(None) - - def cb(response): - xmppURI = response['uri'] - d = self.client.subscribe(xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb2(xmppURI): - d = self.client.publish(TEST_ENTRY, xmppURI) - return d - - - self.client.callback = onNotification - self.client.deferred = defer.Deferred() - d = self.client.create() - d.addCallback(cb) - d.addCallback(cb2) - return defer.gatherResults([d, self.client.deferred]) - - - def test_subscribeTwiceGetNotification(self): - - def onNotification1(data, headers): - d = client1.stopService() - d.chainDeferred(client1.deferred) - - def onNotification2(data, headers): - d = client2.stopService() - d.chainDeferred(client2.deferred) - - def cb(response): - xmppURI = response['uri'] - d = client1.subscribe(xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb2(xmppURI): - d = client2.subscribe(xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb3(xmppURI): - d = self.client.publish(TEST_ENTRY, xmppURI) - return d - - - client1 = gateway.GatewayClient(baseURI, callbackPort=8088) - client1.startService() - client1.callback = onNotification1 - client1.deferred = defer.Deferred() - client2 = gateway.GatewayClient(baseURI, callbackPort=8089) - client2.startService() - client2.callback = onNotification2 - client2.deferred = defer.Deferred() - - d = self.client.create() - d.addCallback(cb) - d.addCallback(cb2) - d.addCallback(cb3) - dl = defer.gatherResults([d, client1.deferred, client2.deferred]) - return dl - - - def test_subscribeGetDelayedNotification(self): - - def onNotification(data, headers): - self.client.deferred.callback(None) - - def cb(response): - xmppURI = response['uri'] - self.assertNot(self.client.deferred.called) - d = self.client.publish(TEST_ENTRY, xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb2(xmppURI): - d = self.client.subscribe(xmppURI) - return d - - - self.client.callback = onNotification - self.client.deferred = defer.Deferred() - d = self.client.create() - d.addCallback(cb) - d.addCallback(cb2) - return defer.gatherResults([d, self.client.deferred]) - - def test_subscribeGetDelayedNotification2(self): - """ - Test that subscribing as second results in a notification being sent. - """ - - def onNotification1(data, headers): - client1.deferred.callback(None) - client1.stopService() - - def onNotification2(data, headers): - client2.deferred.callback(None) - client2.stopService() - - def cb(response): - xmppURI = response['uri'] - self.assertNot(client1.deferred.called) - self.assertNot(client2.deferred.called) - d = self.client.publish(TEST_ENTRY, xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb2(xmppURI): - d = client1.subscribe(xmppURI) - d.addCallback(lambda _: xmppURI) - return d - - def cb3(xmppURI): - d = client2.subscribe(xmppURI) - return d - - client1 = gateway.GatewayClient(baseURI, callbackPort=8088) - client1.startService() - client1.callback = onNotification1 - client1.deferred = defer.Deferred() - client2 = gateway.GatewayClient(baseURI, callbackPort=8089) - client2.startService() - client2.callback = onNotification2 - client2.deferred = defer.Deferred() - - - d = self.client.create() - d.addCallback(cb) - d.addCallback(cb2) - d.addCallback(cb3) - dl = defer.gatherResults([d, client1.deferred, client2.deferred]) - return dl - - - def test_subscribeNonExisting(self): - def cb(err): - self.assertEqual('403', err.status) - - d = self.client.subscribe('xmpp:%s?node=test' % component) - self.assertFailure(d, error.Error) - d.addCallback(cb) - return d - - - def test_subscribeRootGetNotification(self): - - def clean(rootNode): - return self.client.unsubscribe(rootNode) - - def onNotification(data, headers): - self.client.deferred.callback(None) - - def cb(response): - xmppURI = response['uri'] - jid, nodeIdentifier = gateway.getServiceAndNode(xmppURI) - rootNode = gateway.getXMPPURI(jid, '') - - d = self.client.subscribe(rootNode) - d.addCallback(lambda _: self.addCleanup(clean, rootNode)) - d.addCallback(lambda _: xmppURI) - return d - - def cb2(xmppURI): - return self.client.publish(TEST_ENTRY, xmppURI) - - - self.client.callback = onNotification - self.client.deferred = defer.Deferred() - d = self.client.create() - d.addCallback(cb) - d.addCallback(cb2) - return defer.gatherResults([d, self.client.deferred]) - - - def test_unsubscribeNonExisting(self): - def cb(err): - self.assertEqual('403', err.status) - - d = self.client.unsubscribe('xmpp:%s?node=test' % component) - self.assertFailure(d, error.Error) - d.addCallback(cb) - return d - - - def test_items(self): - def cb(response): - xmppURI = response['uri'] - d = self.client.items(xmppURI) - return d - - d = self.client.publish(TEST_ENTRY) - d.addCallback(cb) - return d - - - def test_itemsMaxItems(self): - def cb(response): - xmppURI = response['uri'] - d = self.client.items(xmppURI, 2) - return d - - d = self.client.publish(TEST_ENTRY) - d.addCallback(cb) - return d diff -r 105a0772eedd -r c56a728412f1 src/test/test_storage.py --- a/src/test/test_storage.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,642 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2019 Jérôme Poisson - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -""" -Tests for L{idavoll.memory_storage} and L{idavoll.pgsql_storage}. -""" - -from zope.interface.verify import verifyObject -from twisted.trial import unittest -from twisted.words.protocols.jabber import jid -from twisted.internet import defer -from twisted.words.xish import domish - -from sat_pubsub import error, iidavoll, const - -OWNER = jid.JID('owner@example.com/Work') -SUBSCRIBER = jid.JID('subscriber@example.com/Home') -SUBSCRIBER_NEW = jid.JID('new@example.com/Home') -SUBSCRIBER_TO_BE_DELETED = jid.JID('to_be_deleted@example.com/Home') -SUBSCRIBER_PENDING = jid.JID('pending@example.com/Home') -PUBLISHER = jid.JID('publisher@example.com') -ITEM = domish.Element((None, 'item')) -ITEM['id'] = 'current' -ITEM.addElement(('testns', 'test'), content=u'Test \u2083 item') -ITEM_NEW = domish.Element((None, 'item')) -ITEM_NEW['id'] = 'new' -ITEM_NEW.addElement(('testns', 'test'), content=u'Test \u2083 item') -ITEM_UPDATED = domish.Element((None, 'item')) -ITEM_UPDATED['id'] = 'current' -ITEM_UPDATED.addElement(('testns', 'test'), content=u'Test \u2084 item') -ITEM_TO_BE_DELETED = domish.Element((None, 'item')) -ITEM_TO_BE_DELETED['id'] = 'to-be-deleted' -ITEM_TO_BE_DELETED.addElement(('testns', 'test'), content=u'Test \u2083 item') - -def decode(object): - if isinstance(object, str): - object = object.decode('utf-8') - return object - - - -class StorageTests: - - def _assignTestNode(self, node): - self.node = node - - - def setUp(self): - d = self.s.getNode('pre-existing') - d.addCallback(self._assignTestNode) - return d - - - def test_interfaceIStorage(self): - self.assertTrue(verifyObject(iidavoll.IStorage, self.s)) - - - def test_interfaceINode(self): - self.assertTrue(verifyObject(iidavoll.INode, self.node)) - - - def test_interfaceILeafNode(self): - self.assertTrue(verifyObject(iidavoll.ILeafNode, self.node)) - - - def test_getNode(self): - return self.s.getNode('pre-existing') - - - def test_getNonExistingNode(self): - d = self.s.getNode('non-existing') - self.assertFailure(d, error.NodeNotFound) - return d - - - def test_getNodeIDs(self): - def cb(nodeIdentifiers): - self.assertIn('pre-existing', nodeIdentifiers) - self.assertNotIn('non-existing', nodeIdentifiers) - - return self.s.getNodeIds().addCallback(cb) - - - def test_createExistingNode(self): - config = self.s.getDefaultConfiguration('leaf') - config['pubsub#node_type'] = 'leaf' - d = self.s.createNode('pre-existing', OWNER, config) - self.assertFailure(d, error.NodeExists) - return d - - - def test_createNode(self): - def cb(void): - d = self.s.getNode('new 1') - return d - - config = self.s.getDefaultConfiguration('leaf') - config['pubsub#node_type'] = 'leaf' - d = self.s.createNode('new 1', OWNER, config) - d.addCallback(cb) - return d - - - def test_createNodeChangingConfig(self): - """ - The configuration passed to createNode must be free to be changed. - """ - def cb(result): - node1, node2 = result - self.assertTrue(node1.getConfiguration()['pubsub#persist_items']) - - config = { - "pubsub#persist_items": True, - "pubsub#deliver_payloads": True, - "pubsub#send_last_published_item": 'on_sub', - "pubsub#node_type": 'leaf', - "pubsub#access_model": 'open', - const.OPT_PUBLISH_MODEL: const.VAL_PMODEL_OPEN - } - - def unsetPersistItems(_): - config["pubsub#persist_items"] = False - - d = defer.succeed(None) - d.addCallback(lambda _: self.s.createNode('new 1', OWNER, config)) - d.addCallback(unsetPersistItems) - d.addCallback(lambda _: self.s.createNode('new 2', OWNER, config)) - d.addCallback(lambda _: defer.gatherResults([ - self.s.getNode('new 1'), - self.s.getNode('new 2')])) - d.addCallback(cb) - return d - - - def test_deleteNonExistingNode(self): - d = self.s.deleteNode('non-existing') - self.assertFailure(d, error.NodeNotFound) - return d - - - def test_deleteNode(self): - def cb(void): - d = self.s.getNode('to-be-deleted') - self.assertFailure(d, error.NodeNotFound) - return d - - d = self.s.deleteNode('to-be-deleted') - d.addCallback(cb) - return d - - - def test_getAffiliations(self): - def cb(affiliations): - self.assertIn(('pre-existing', 'owner'), affiliations) - - d = self.s.getAffiliations(OWNER) - d.addCallback(cb) - return d - - - def test_getSubscriptions(self): - def cb(subscriptions): - found = False - for subscription in subscriptions: - if (subscription.nodeIdentifier == 'pre-existing' and - subscription.subscriber == SUBSCRIBER and - subscription.state == 'subscribed'): - found = True - self.assertTrue(found) - - d = self.s.getSubscriptions(SUBSCRIBER) - d.addCallback(cb) - return d - - - # Node tests - - def test_getType(self): - self.assertEqual(self.node.getType(), 'leaf') - - - def test_getConfiguration(self): - config = self.node.getConfiguration() - self.assertIn('pubsub#persist_items', config.iterkeys()) - self.assertIn('pubsub#deliver_payloads', config.iterkeys()) - self.assertEqual(config['pubsub#persist_items'], True) - self.assertEqual(config['pubsub#deliver_payloads'], True) - - - def test_setConfiguration(self): - def getConfig(node): - d = node.setConfiguration({'pubsub#persist_items': False}) - d.addCallback(lambda _: node) - return d - - def checkObjectConfig(node): - config = node.getConfiguration() - self.assertEqual(config['pubsub#persist_items'], False) - - def getNode(void): - return self.s.getNode('to-be-reconfigured') - - def checkStorageConfig(node): - config = node.getConfiguration() - self.assertEqual(config['pubsub#persist_items'], False) - - d = self.s.getNode('to-be-reconfigured') - d.addCallback(getConfig) - d.addCallback(checkObjectConfig) - d.addCallback(getNode) - d.addCallback(checkStorageConfig) - return d - - - def test_getMetaData(self): - metaData = self.node.getMetaData() - for key, value in self.node.getConfiguration().iteritems(): - self.assertIn(key, metaData.iterkeys()) - self.assertEqual(value, metaData[key]) - self.assertIn('pubsub#node_type', metaData.iterkeys()) - self.assertEqual(metaData['pubsub#node_type'], 'leaf') - - - def test_getAffiliation(self): - def cb(affiliation): - self.assertEqual(affiliation, 'owner') - - d = self.node.getAffiliation(OWNER) - d.addCallback(cb) - return d - - - def test_getNonExistingAffiliation(self): - def cb(affiliation): - self.assertEqual(affiliation, None) - - d = self.node.getAffiliation(SUBSCRIBER) - d.addCallback(cb) - return d - - - def test_addSubscription(self): - def cb1(void): - return self.node.getSubscription(SUBSCRIBER_NEW) - - def cb2(subscription): - self.assertEqual(subscription.state, 'pending') - - d = self.node.addSubscription(SUBSCRIBER_NEW, 'pending', {}) - d.addCallback(cb1) - d.addCallback(cb2) - return d - - - def test_addExistingSubscription(self): - d = self.node.addSubscription(SUBSCRIBER, 'pending', {}) - self.assertFailure(d, error.SubscriptionExists) - return d - - - def test_getSubscription(self): - def cb(subscriptions): - self.assertEquals(subscriptions[0].state, 'subscribed') - self.assertEquals(subscriptions[1].state, 'pending') - self.assertEquals(subscriptions[2], None) - - d = defer.gatherResults([self.node.getSubscription(SUBSCRIBER), - self.node.getSubscription(SUBSCRIBER_PENDING), - self.node.getSubscription(OWNER)]) - d.addCallback(cb) - return d - - - def test_removeSubscription(self): - return self.node.removeSubscription(SUBSCRIBER_TO_BE_DELETED) - - - def test_removeNonExistingSubscription(self): - d = self.node.removeSubscription(OWNER) - self.assertFailure(d, error.NotSubscribed) - return d - - - def test_getNodeSubscriptions(self): - def extractSubscribers(subscriptions): - return [subscription.subscriber for subscription in subscriptions] - - def cb(subscribers): - self.assertIn(SUBSCRIBER, subscribers) - self.assertNotIn(SUBSCRIBER_PENDING, subscribers) - self.assertNotIn(OWNER, subscribers) - - d = self.node.getSubscriptions('subscribed') - d.addCallback(extractSubscribers) - d.addCallback(cb) - return d - - - def test_isSubscriber(self): - def cb(subscribed): - self.assertEquals(subscribed[0][1], True) - self.assertEquals(subscribed[1][1], True) - self.assertEquals(subscribed[2][1], False) - self.assertEquals(subscribed[3][1], False) - - d = defer.DeferredList([self.node.isSubscribed(SUBSCRIBER), - self.node.isSubscribed(SUBSCRIBER.userhostJID()), - self.node.isSubscribed(SUBSCRIBER_PENDING), - self.node.isSubscribed(OWNER)]) - d.addCallback(cb) - return d - - - def test_storeItems(self): - def cb1(void): - return self.node.getItemsById("", False, ['new']) - - def cb2(result): - self.assertEqual(ITEM_NEW.toXml(), result[0].toXml()) - - d = self.node.storeItems([(const.VAL_AMODEL_DEFAULT, {}, ITEM_NEW)], PUBLISHER) - d.addCallback(cb1) - d.addCallback(cb2) - return d - - - def test_storeUpdatedItems(self): - def cb1(void): - return self.node.getItemsById("", False, ['current']) - - def cb2(result): - self.assertEqual(ITEM_UPDATED.toXml(), result[0].toXml()) - - d = self.node.storeItems([(const.VAL_AMODEL_DEFAULT, {}, ITEM_UPDATED)], PUBLISHER) - d.addCallback(cb1) - d.addCallback(cb2) - return d - - - def test_removeItems(self): - def cb1(result): - self.assertEqual(['to-be-deleted'], result) - return self.node.getItemsById("", False, ['to-be-deleted']) - - def cb2(result): - self.assertEqual(0, len(result)) - - d = self.node.removeItems(['to-be-deleted']) - d.addCallback(cb1) - d.addCallback(cb2) - return d - - - def test_removeNonExistingItems(self): - def cb(result): - self.assertEqual([], result) - - d = self.node.removeItems(['non-existing']) - d.addCallback(cb) - return d - - - def test_getItems(self): - def cb(result): - items = [item.toXml() for item in result] - self.assertIn(ITEM.toXml(), items) - d = self.node.getItems("", False) - d.addCallback(cb) - return d - - - def test_lastItem(self): - def cb(result): - self.assertEqual(1, len(result)) - self.assertEqual(ITEM.toXml(), result[0].toXml()) - - d = self.node.getItems("", False, 1) - d.addCallback(cb) - return d - - - def test_getItemsById(self): - def cb(result): - self.assertEqual(1, len(result)) - - d = self.node.getItemsById("", False, ['current']) - d.addCallback(cb) - return d - - - def test_getNonExistingItemsById(self): - def cb(result): - self.assertEqual(0, len(result)) - - d = self.node.getItemsById("", False, ['non-existing']) - d.addCallback(cb) - return d - - - def test_purge(self): - def cb1(node): - d = node.purge() - d.addCallback(lambda _: node) - return d - - def cb2(node): - return node.getItems("", False) - - def cb3(result): - self.assertEqual([], result) - - d = self.s.getNode('to-be-purged') - d.addCallback(cb1) - d.addCallback(cb2) - d.addCallback(cb3) - return d - - - def test_getNodeAffilatiations(self): - def cb1(node): - return node.getAffiliations() - - def cb2(affiliations): - affiliations = dict(((a[0].full(), a[1]) for a in affiliations)) - self.assertEquals(affiliations[OWNER.userhost()], 'owner') - - d = self.s.getNode('pre-existing') - d.addCallback(cb1) - d.addCallback(cb2) - return d - - - -class MemoryStorageStorageTestCase(unittest.TestCase, StorageTests): - - def setUp(self): - from sat_pubsub.memory_storage import Storage, PublishedItem, LeafNode - from sat_pubsub.memory_storage import Subscription - - defaultConfig = Storage.defaultConfig['leaf'] - - self.s = Storage() - self.s._nodes['pre-existing'] = \ - LeafNode('pre-existing', OWNER, defaultConfig) - self.s._nodes['to-be-deleted'] = \ - LeafNode('to-be-deleted', OWNER, None) - self.s._nodes['to-be-reconfigured'] = \ - LeafNode('to-be-reconfigured', OWNER, defaultConfig) - self.s._nodes['to-be-purged'] = \ - LeafNode('to-be-purged', OWNER, None) - - subscriptions = self.s._nodes['pre-existing']._subscriptions - subscriptions[SUBSCRIBER.full()] = Subscription('pre-existing', - SUBSCRIBER, - 'subscribed') - subscriptions[SUBSCRIBER_TO_BE_DELETED.full()] = \ - Subscription('pre-existing', SUBSCRIBER_TO_BE_DELETED, - 'subscribed') - subscriptions[SUBSCRIBER_PENDING.full()] = \ - Subscription('pre-existing', SUBSCRIBER_PENDING, - 'pending') - - item = PublishedItem(ITEM_TO_BE_DELETED, PUBLISHER) - self.s._nodes['pre-existing']._items['to-be-deleted'] = item - self.s._nodes['pre-existing']._itemlist.append(item) - self.s._nodes['to-be-purged']._items['to-be-deleted'] = item - self.s._nodes['to-be-purged']._itemlist.append(item) - item = PublishedItem(ITEM, PUBLISHER) - self.s._nodes['pre-existing']._items['current'] = item - self.s._nodes['pre-existing']._itemlist.append(item) - - return StorageTests.setUp(self) - - - -class PgsqlStorageStorageTestCase(unittest.TestCase, StorageTests): - - dbpool = None - - def setUp(self): - from sat_pubsub.pgsql_storage import Storage - from twisted.enterprise import adbapi - if self.dbpool is None: - self.__class__.dbpool = adbapi.ConnectionPool('psycopg2', - database='pubsub_test', - cp_reconnect=True, - client_encoding='utf-8', - connection_factory=NamedTupleConnection, - ) - self.s = Storage(self.dbpool) - self.dbpool.start() - d = self.dbpool.runInteraction(self.init) - d.addCallback(lambda _: StorageTests.setUp(self)) - return d - - - def tearDown(self): - d = self.dbpool.runInteraction(self.cleandb) - return d.addCallback(lambda _: self.dbpool.close()) - - - def init(self, cursor): - self.cleandb(cursor) - cursor.execute("""INSERT INTO nodes - (node, node_type, persist_items) - VALUES ('pre-existing', 'leaf', TRUE)""") - cursor.execute("""INSERT INTO nodes (node) VALUES ('to-be-deleted')""") - cursor.execute("""INSERT INTO nodes (node) VALUES ('to-be-reconfigured')""") - cursor.execute("""INSERT INTO nodes (node) VALUES ('to-be-purged')""") - cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", - (OWNER.userhost(),)) - cursor.execute("""INSERT INTO affiliations - (node_id, entity_id, affiliation) - SELECT node_id, entity_id, 'owner' - FROM nodes, entities - WHERE node='pre-existing' AND jid=%s""", - (OWNER.userhost(),)) - cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", - (SUBSCRIBER.userhost(),)) - cursor.execute("""INSERT INTO subscriptions - (node_id, entity_id, resource, state) - SELECT node_id, entity_id, %s, 'subscribed' - FROM nodes, entities - WHERE node='pre-existing' AND jid=%s""", - (SUBSCRIBER.resource, - SUBSCRIBER.userhost())) - cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", - (SUBSCRIBER_TO_BE_DELETED.userhost(),)) - cursor.execute("""INSERT INTO subscriptions - (node_id, entity_id, resource, state) - SELECT node_id, entity_id, %s, 'subscribed' - FROM nodes, entities - WHERE node='pre-existing' AND jid=%s""", - (SUBSCRIBER_TO_BE_DELETED.resource, - SUBSCRIBER_TO_BE_DELETED.userhost())) - cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", - (SUBSCRIBER_PENDING.userhost(),)) - cursor.execute("""INSERT INTO subscriptions - (node_id, entity_id, resource, state) - SELECT node_id, entity_id, %s, 'pending' - FROM nodes, entities - WHERE node='pre-existing' AND jid=%s""", - (SUBSCRIBER_PENDING.resource, - SUBSCRIBER_PENDING.userhost())) - cursor.execute("""INSERT INTO entities (jid) VALUES (%s)""", - (PUBLISHER.userhost(),)) - cursor.execute("""INSERT INTO items - (node_id, publisher, item, data, created) - SELECT node_id, %s, 'to-be-deleted', %s, - now() - interval '1 day' - FROM nodes - WHERE node='pre-existing'""", - (PUBLISHER.userhost(), - ITEM_TO_BE_DELETED.toXml())) - cursor.execute("""INSERT INTO items (node_id, publisher, item, data) - SELECT node_id, %s, 'to-be-deleted', %s - FROM nodes - WHERE node='to-be-purged'""", - (PUBLISHER.userhost(), - ITEM_TO_BE_DELETED.toXml())) - cursor.execute("""INSERT INTO items (node_id, publisher, item, data) - SELECT node_id, %s, 'current', %s - FROM nodes - WHERE node='pre-existing'""", - (PUBLISHER.userhost(), - ITEM.toXml())) - - - def cleandb(self, cursor): - cursor.execute("""DELETE FROM nodes WHERE node in - ('non-existing', 'pre-existing', 'to-be-deleted', - 'new 1', 'new 2', 'new 3', 'to-be-reconfigured', - 'to-be-purged')""") - cursor.execute("""DELETE FROM entities WHERE jid=%s""", - (OWNER.userhost(),)) - cursor.execute("""DELETE FROM entities WHERE jid=%s""", - (SUBSCRIBER.userhost(),)) - cursor.execute("""DELETE FROM entities WHERE jid=%s""", - (SUBSCRIBER_NEW.userhost(),)) - cursor.execute("""DELETE FROM entities WHERE jid=%s""", - (SUBSCRIBER_TO_BE_DELETED.userhost(),)) - cursor.execute("""DELETE FROM entities WHERE jid=%s""", - (SUBSCRIBER_PENDING.userhost(),)) - cursor.execute("""DELETE FROM entities WHERE jid=%s""", - (PUBLISHER.userhost(),)) - - -try: - import psycopg2 - psycopg2 - from psycopg2.extras import NamedTupleConnection -except ImportError: - PgsqlStorageStorageTestCase.skip = "psycopg2 not available" diff -r 105a0772eedd -r c56a728412f1 src/twisted/plugins/pubsub.py --- a/src/twisted/plugins/pubsub.py Wed Jul 24 19:26:43 2019 +0200 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,276 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2019 Jérôme Poisson -# Copyright (c) 2003-2011 Ralph Meijer - - -# 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 . -# -- - -# This program is based on Idavoll (http://idavoll.ik.nu/), -# originaly written by Ralph Meijer (http://ralphm.net/blog/) -# It is sublicensed under AGPL v3 (or any later version) as allowed by the original -# license. - -# -- - -# Here is a copy of the original license: - -# Copyright (c) 2003-2011 Ralph Meijer - -# Permission is hereby granted, free of charge, to any person obtaining -# a copy of this software and associated documentation files (the -# "Software"), to deal in the Software without restriction, including -# without limitation the rights to use, copy, modify, merge, publish, -# distribute, sublicense, and/or sell copies of the Software, and to -# permit persons to whom the Software is furnished to do so, subject to -# the following conditions: - -# The above copyright notice and this permission notice shall be -# included in all copies or substantial portions of the Software. - -# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND -# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE -# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION -# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION -# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - -import csv -import sat_pubsub -import sys -from twisted.application.service import IServiceMaker -from twisted.application import service -from twisted.python import usage, log -from twisted.words.protocols.jabber.jid import JID -from twisted.plugin import IPlugin - -from wokkel.component import Component -from wokkel.disco import DiscoHandler -from wokkel.generic import FallbackHandler, VersionHandler -from wokkel.iwokkel import IPubSubResource -from wokkel import data_form -from wokkel import pubsub -from wokkel import rsm -from wokkel import mam -from zope.interface import implements - -from sat_pubsub import const -from sat_pubsub import mam as pubsub_mam -from sat_pubsub import pubsub_admin -from sat_pubsub.backend import BackendService, ExtraDiscoHandler -from sat_pubsub.schema import SchemaHandler -from sat_pubsub.privilege import PrivilegesHandler -from sat_pubsub.delegation import DelegationsHandler -from os.path import expanduser, realpath -import ConfigParser - - -def coerceListType(value): - return csv.reader( - [value], delimiter=",", quotechar='"', skipinitialspace=True - ).next() - - -def coerceJidListType(value): - values = [JID(v) for v in coerceListType(value)] - if any((j.resource for j in values)): - raise ValueError(u"you must use bare jids") - return values - - - -OPT_PARAMETERS_BOTH = [ - ['jid', None, None, 'JID this component will be available at'], - ['xmpp_pwd', None, None, 'XMPP server component password'], - ['rhost', None, '127.0.0.1', 'XMPP server host'], - ['rport', None, '5347', 'XMPP server port'], - ['backend', None, 'pgsql', 'Choice of storage backend'], - ['db_user', None, None, 'Database user (pgsql backend)'], - ['db_name', None, 'pubsub', 'Database name (pgsql backend)'], - ['db_pass', None, None, 'Database password (pgsql backend)'], - ['db_host', None, None, 'Database host (pgsql backend)'], - ['db_port', None, None, 'Database port (pgsql backend)'], - ] -# here for future use -OPT_PARAMETERS_CFG = [ - ["admins_jids_list", None, [], "List of administrators' bare jids", - coerceJidListType] - ] - -CONFIG_FILENAME = u'sat' -# List of the configuration filenames sorted by ascending priority -CONFIG_FILES = [realpath(expanduser(path) + CONFIG_FILENAME + '.conf') for path in ( - '/etc/', '/etc/{}/'.format(CONFIG_FILENAME), - '~/', '~/.', - '.config/', '.config/.', - '', '.')] -CONFIG_SECTION = 'pubsub' - - -class Options(usage.Options): - optParameters = OPT_PARAMETERS_BOTH - - optFlags = [ - ('verbose', 'v', 'Show traffic'), - ('hide-nodes', None, 'Hide all nodes for disco') - ] - - def __init__(self): - """Read SàT Pubsub configuration file in order to overwrite the hard-coded default values. - - Priority for the usage of the values is (from lowest to highest): - - hard-coded default values - - values from SàT configuration files - - values passed on the command line - """ - # If we do it the reading later: after the command line options have been parsed, there's no good way to know - # if the options values are the hard-coded ones or if they have been passed on the command line. - - # FIXME: must be refactored + code can be factorised with backend - config_parser = ConfigParser.SafeConfigParser() - config_parser.read(CONFIG_FILES) - for param in self.optParameters + OPT_PARAMETERS_CFG: - name = param[0] - try: - value = config_parser.get(CONFIG_SECTION, name) - if isinstance(value, unicode): - value = value.encode('utf-8') - try: - param[2] = param[4](value) - except IndexError: # the coerce method is optional - param[2] = value - except Exception as e: - log.err(u'Invalid value for setting "{name}": {msg}'.format( - name=name, msg=e)) - sys.exit(1) - except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): - pass - usage.Options.__init__(self) - for opt_data in OPT_PARAMETERS_CFG: - self[opt_data[0]] = opt_data[2] - - def postOptions(self): - if self['backend'] not in ['pgsql', 'memory']: - raise usage.UsageError, "Unknown backend!" - if self['backend'] == 'memory': - raise NotImplementedError('memory backend is not available at the moment') - - self['jid'] = JID(self['jid']) if self['jid'] else None - - -class SatPubsubMaker(object): - implements(IServiceMaker, IPlugin) - tapname = "sat-pubsub" - description = u"Salut à Toi Publish-Subscribe Service Component".encode('utf-8') - options = Options - - def makeService(self, config): - if not config['jid'] or not config['xmpp_pwd']: - raise usage.UsageError("You must specify jid and xmpp_pwd") - s = service.MultiService() - - # Create backend service with storage - - if config['backend'] == 'pgsql': - from twisted.enterprise import adbapi - from sat_pubsub.pgsql_storage import Storage - from psycopg2.extras import NamedTupleConnection - keys_map = { - 'db_user': 'user', - 'db_pass': 'password', - 'db_name': 'database', - 'db_host': 'host', - 'db_port': 'port', - } - kwargs = {} - for config_k, k in keys_map.iteritems(): - v = config.get(config_k) - if v is None: - continue - kwargs[k] = v - dbpool = adbapi.ConnectionPool('psycopg2', - cp_reconnect=True, - client_encoding='utf-8', - connection_factory=NamedTupleConnection, - **kwargs - ) - st = Storage(dbpool) - elif config['backend'] == 'memory': - from sat_pubsub.memory_storage import Storage - st = Storage() - - bs = BackendService(st, config) - bs.setName('backend') - bs.setServiceParent(s) - - # Set up XMPP server-side component with publish-subscribe capabilities - - cs = Component(config["rhost"], int(config["rport"]), - config["jid"].full(), config["xmpp_pwd"]) - cs.setName('component') - cs.setServiceParent(s) - - cs.factory.maxDelay = 900 - - if config["verbose"]: - cs.logTraffic = True - - FallbackHandler().setHandlerParent(cs) - VersionHandler(u'SàT Pubsub', sat_pubsub.__version__).setHandlerParent(cs) - DiscoHandler().setHandlerParent(cs) - - ph = PrivilegesHandler(config['jid']) - ph.setHandlerParent(cs) - bs.privilege = ph - - resource = IPubSubResource(bs) - resource.hideNodes = config["hide-nodes"] - resource.serviceJID = config["jid"] - - ps = (rsm if const.FLAG_ENABLE_RSM else pubsub).PubSubService(resource) - ps.setHandlerParent(cs) - resource.pubsubService = ps - - if const.FLAG_ENABLE_MAM: - mam_resource = pubsub_mam.MAMResource(bs) - mam_s = mam.MAMService(mam_resource) - mam_s.addFilter(data_form.Field(var=const.MAM_FILTER_CATEGORY)) - mam_s.setHandlerParent(cs) - - pa = pubsub_admin.PubsubAdminHandler(bs) - pa.setHandlerParent(cs) - - sh = SchemaHandler() - sh.setHandlerParent(cs) - - # wokkel.pubsub doesn't handle non pubsub# disco - # and we need to announce other feature, so this is a workaround - # to add them - # FIXME: propose a patch upstream to fix this situation - ed = ExtraDiscoHandler() - ed.setHandlerParent(cs) - - # XXX: delegation must be instancied at the end, - # because it does some MonkeyPatching on handlers - dh = DelegationsHandler() - dh.setHandlerParent(cs) - bs.delegation = dh - - return s - -serviceMaker = SatPubsubMaker() diff -r 105a0772eedd -r c56a728412f1 twisted/plugins/pubsub.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/twisted/plugins/pubsub.py Fri Aug 16 12:00:02 2019 +0200 @@ -0,0 +1,276 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2019 Jérôme Poisson +# Copyright (c) 2003-2011 Ralph Meijer + + +# 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 . +# -- + +# This program is based on Idavoll (http://idavoll.ik.nu/), +# originaly written by Ralph Meijer (http://ralphm.net/blog/) +# It is sublicensed under AGPL v3 (or any later version) as allowed by the original +# license. + +# -- + +# Here is a copy of the original license: + +# Copyright (c) 2003-2011 Ralph Meijer + +# Permission is hereby granted, free of charge, to any person obtaining +# a copy of this software and associated documentation files (the +# "Software"), to deal in the Software without restriction, including +# without limitation the rights to use, copy, modify, merge, publish, +# distribute, sublicense, and/or sell copies of the Software, and to +# permit persons to whom the Software is furnished to do so, subject to +# the following conditions: + +# The above copyright notice and this permission notice shall be +# included in all copies or substantial portions of the Software. + +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +# NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +# LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +# OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +# WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + + +import csv +import sat_pubsub +import sys +from twisted.application.service import IServiceMaker +from twisted.application import service +from twisted.python import usage, log +from twisted.words.protocols.jabber.jid import JID +from twisted.plugin import IPlugin + +from wokkel.component import Component +from wokkel.disco import DiscoHandler +from wokkel.generic import FallbackHandler, VersionHandler +from wokkel.iwokkel import IPubSubResource +from wokkel import data_form +from wokkel import pubsub +from wokkel import rsm +from wokkel import mam +from zope.interface import implements + +from sat_pubsub import const +from sat_pubsub import mam as pubsub_mam +from sat_pubsub import pubsub_admin +from sat_pubsub.backend import BackendService, ExtraDiscoHandler +from sat_pubsub.schema import SchemaHandler +from sat_pubsub.privilege import PrivilegesHandler +from sat_pubsub.delegation import DelegationsHandler +from os.path import expanduser, realpath +import ConfigParser + + +def coerceListType(value): + return csv.reader( + [value], delimiter=",", quotechar='"', skipinitialspace=True + ).next() + + +def coerceJidListType(value): + values = [JID(v) for v in coerceListType(value)] + if any((j.resource for j in values)): + raise ValueError(u"you must use bare jids") + return values + + + +OPT_PARAMETERS_BOTH = [ + ['jid', None, None, 'JID this component will be available at'], + ['xmpp_pwd', None, None, 'XMPP server component password'], + ['rhost', None, '127.0.0.1', 'XMPP server host'], + ['rport', None, '5347', 'XMPP server port'], + ['backend', None, 'pgsql', 'Choice of storage backend'], + ['db_user', None, None, 'Database user (pgsql backend)'], + ['db_name', None, 'pubsub', 'Database name (pgsql backend)'], + ['db_pass', None, None, 'Database password (pgsql backend)'], + ['db_host', None, None, 'Database host (pgsql backend)'], + ['db_port', None, None, 'Database port (pgsql backend)'], + ] +# here for future use +OPT_PARAMETERS_CFG = [ + ["admins_jids_list", None, [], "List of administrators' bare jids", + coerceJidListType] + ] + +CONFIG_FILENAME = u'sat' +# List of the configuration filenames sorted by ascending priority +CONFIG_FILES = [realpath(expanduser(path) + CONFIG_FILENAME + '.conf') for path in ( + '/etc/', '/etc/{}/'.format(CONFIG_FILENAME), + '~/', '~/.', + '.config/', '.config/.', + '', '.')] +CONFIG_SECTION = 'pubsub' + + +class Options(usage.Options): + optParameters = OPT_PARAMETERS_BOTH + + optFlags = [ + ('verbose', 'v', 'Show traffic'), + ('hide-nodes', None, 'Hide all nodes for disco') + ] + + def __init__(self): + """Read SàT Pubsub configuration file in order to overwrite the hard-coded default values. + + Priority for the usage of the values is (from lowest to highest): + - hard-coded default values + - values from SàT configuration files + - values passed on the command line + """ + # If we do it the reading later: after the command line options have been parsed, there's no good way to know + # if the options values are the hard-coded ones or if they have been passed on the command line. + + # FIXME: must be refactored + code can be factorised with backend + config_parser = ConfigParser.SafeConfigParser() + config_parser.read(CONFIG_FILES) + for param in self.optParameters + OPT_PARAMETERS_CFG: + name = param[0] + try: + value = config_parser.get(CONFIG_SECTION, name) + if isinstance(value, unicode): + value = value.encode('utf-8') + try: + param[2] = param[4](value) + except IndexError: # the coerce method is optional + param[2] = value + except Exception as e: + log.err(u'Invalid value for setting "{name}": {msg}'.format( + name=name, msg=e)) + sys.exit(1) + except (ConfigParser.NoSectionError, ConfigParser.NoOptionError): + pass + usage.Options.__init__(self) + for opt_data in OPT_PARAMETERS_CFG: + self[opt_data[0]] = opt_data[2] + + def postOptions(self): + if self['backend'] not in ['pgsql', 'memory']: + raise usage.UsageError, "Unknown backend!" + if self['backend'] == 'memory': + raise NotImplementedError('memory backend is not available at the moment') + + self['jid'] = JID(self['jid']) if self['jid'] else None + + +class SatPubsubMaker(object): + implements(IServiceMaker, IPlugin) + tapname = "sat-pubsub" + description = u"Salut à Toi Publish-Subscribe Service Component".encode('utf-8') + options = Options + + def makeService(self, config): + if not config['jid'] or not config['xmpp_pwd']: + raise usage.UsageError("You must specify jid and xmpp_pwd") + s = service.MultiService() + + # Create backend service with storage + + if config['backend'] == 'pgsql': + from twisted.enterprise import adbapi + from sat_pubsub.pgsql_storage import Storage + from psycopg2.extras import NamedTupleConnection + keys_map = { + 'db_user': 'user', + 'db_pass': 'password', + 'db_name': 'database', + 'db_host': 'host', + 'db_port': 'port', + } + kwargs = {} + for config_k, k in keys_map.iteritems(): + v = config.get(config_k) + if v is None: + continue + kwargs[k] = v + dbpool = adbapi.ConnectionPool('psycopg2', + cp_reconnect=True, + client_encoding='utf-8', + connection_factory=NamedTupleConnection, + **kwargs + ) + st = Storage(dbpool) + elif config['backend'] == 'memory': + from sat_pubsub.memory_storage import Storage + st = Storage() + + bs = BackendService(st, config) + bs.setName('backend') + bs.setServiceParent(s) + + # Set up XMPP server-side component with publish-subscribe capabilities + + cs = Component(config["rhost"], int(config["rport"]), + config["jid"].full(), config["xmpp_pwd"]) + cs.setName('component') + cs.setServiceParent(s) + + cs.factory.maxDelay = 900 + + if config["verbose"]: + cs.logTraffic = True + + FallbackHandler().setHandlerParent(cs) + VersionHandler(u'SàT Pubsub', sat_pubsub.__version__).setHandlerParent(cs) + DiscoHandler().setHandlerParent(cs) + + ph = PrivilegesHandler(config['jid']) + ph.setHandlerParent(cs) + bs.privilege = ph + + resource = IPubSubResource(bs) + resource.hideNodes = config["hide-nodes"] + resource.serviceJID = config["jid"] + + ps = (rsm if const.FLAG_ENABLE_RSM else pubsub).PubSubService(resource) + ps.setHandlerParent(cs) + resource.pubsubService = ps + + if const.FLAG_ENABLE_MAM: + mam_resource = pubsub_mam.MAMResource(bs) + mam_s = mam.MAMService(mam_resource) + mam_s.addFilter(data_form.Field(var=const.MAM_FILTER_CATEGORY)) + mam_s.setHandlerParent(cs) + + pa = pubsub_admin.PubsubAdminHandler(bs) + pa.setHandlerParent(cs) + + sh = SchemaHandler() + sh.setHandlerParent(cs) + + # wokkel.pubsub doesn't handle non pubsub# disco + # and we need to announce other feature, so this is a workaround + # to add them + # FIXME: propose a patch upstream to fix this situation + ed = ExtraDiscoHandler() + ed.setHandlerParent(cs) + + # XXX: delegation must be instancied at the end, + # because it does some MonkeyPatching on handlers + dh = DelegationsHandler() + dh.setHandlerParent(cs) + bs.delegation = dh + + return s + +serviceMaker = SatPubsubMaker()