# HG changeset patch # User Goffi # Date 1519991978 -3600 # Node ID dabee42494ac0bcf00d95364af67e0ffe59f1114 # Parent 618a920808120dcca26847b718436e31fe4da8cc config file + cleaning: - SàT Pubsub can now be configured using the same config file as SàT itself (i.e. sat.conf or .sat.conf), in the same locations (/etc, local dir, xdg dir). Its options must be in the "pubsub" section - options on command line override config options - removed tap and http files which are not used anymore - changed directory structure to put source in src, to be coherent with SàT and Libervia - changed options name, db* become db_*, secret become xmpp_pwd - an exception is raised if jid or xmpp_pwd is are not configured diff -r 618a92080812 -r dabee42494ac sat_pubsub/__init__.py --- a/sat_pubsub/__init__.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,64 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2018 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.2.0' - -# TODO: remove this when RSM and MAM are in wokkel -import wokkel -from sat_tmp.wokkel import pubsub as tmp_pubsub, rsm as tmp_rsm, mam as tmp_mam -wokkel.pubsub = tmp_pubsub -wokkel.rsm = tmp_rsm -wokkel.mam = tmp_mam diff -r 618a92080812 -r dabee42494ac sat_pubsub/backend.py --- a/sat_pubsub/backend.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1699 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- -# -# Copyright (c) 2012-2018 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 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 utility - -from wokkel import disco -from wokkel import data_form -from wokkel import rsm -from wokkel import iwokkel -from wokkel import pubsub - -from sat_pubsub import error -from sat_pubsub import iidavoll -from sat_pubsub import const -from sat_pubsub import container - -from copy import deepcopy - - -def _getAffiliation(node, entity): - d = node.getAffiliation(entity) - d.addCallback(lambda affiliation: (node, affiliation)) - return d - - -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"}, - } - - 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): - utility.EventDispatcher.__init__(self) - self.storage = storage - self._callbackList = [] - - 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 the itemIdentifiers correspond to items published - by the current 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 - 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 - 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 and affiliation != 'owner': - # we don't want a publisher to overwrite the item - # of an other publisher - yield self._checkOverwrite(node, [item['id'] for item in items if item.getAttribute('id')], 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) - - 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, options) - return d - - def _doSetNodeConfiguration(self, result, options): - node, affiliation = result - - if affiliation != 'owner': - 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, schema) - return d - - def _doSetNodeSchema(self, result, schema): - node, affiliation = result - - if affiliation != 'owner': - 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) - return d - - def _doGetAffiliationsOwner(self, result): - node, affiliation = result - - if affiliation != 'owner': - 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': - 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) - return d - - def _doGetSubscriptionsOwner(self, result): - node, affiliation = result - - if affiliation != 'owner': - 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': - 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())): - 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) - return d - - def _doPurge(self, result): - node, affiliation = result - persistItems = node.getConfiguration()[const.OPT_PERSIST_ITEMS] - - if affiliation != 'owner': - 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, redirectURI, pep, recipient) - return d - - def _doPreDelete(self, result, redirectURI, pep, recipient): - node, affiliation = result - - if affiliation != 'owner': - 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 - new_item = deepcopy(item) - if item_config: - new_item.addChild(item_config.toElement()) - return new_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 deepcopy items because different subscribers may receive - # different items (e.g. read restriction in schema) - items_data = deepcopy(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.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: - ext_data['rsm'] = request.rsm - try: - ext_data['pep'] = request.delegated - except AttributeError: - pass - 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) diff -r 618a92080812 -r dabee42494ac sat_pubsub/const.py --- a/sat_pubsub/const.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,84 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2018 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' -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 618a92080812 -r dabee42494ac sat_pubsub/container.py --- a/sat_pubsub/container.py Fri Jan 26 11:16:18 2018 +0100 +++ /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 618a92080812 -r dabee42494ac sat_pubsub/delegation.py --- a/sat_pubsub/delegation.py Fri Jan 26 11:16:18 2018 +0100 +++ /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 618a92080812 -r dabee42494ac sat_pubsub/error.py --- a/sat_pubsub/error.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,152 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac sat_pubsub/exceptions.py --- a/sat_pubsub/exceptions.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,55 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac sat_pubsub/gateway.py --- a/sat_pubsub/gateway.py Fri Jan 26 11:16:18 2018 +0100 +++ /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-2018 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 618a92080812 -r dabee42494ac sat_pubsub/iidavoll.py --- a/sat_pubsub/iidavoll.py Fri Jan 26 11:16:18 2018 +0100 +++ /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-2018 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 618a92080812 -r dabee42494ac sat_pubsub/mam.py --- a/sat_pubsub/mam.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,161 +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 - - 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 - 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 - 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) - - 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 618a92080812 -r dabee42494ac sat_pubsub/memory_storage.py --- a/sat_pubsub/memory_storage.py Fri Jan 26 11:16:18 2018 +0100 +++ /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-2018 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 618a92080812 -r dabee42494ac sat_pubsub/pgsql_storage.py --- a/sat_pubsub/pgsql_storage.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,1340 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2018 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 = '4' -# 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, - }, - '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], - } - schema = row[9] - 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, - 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, - 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, - schema, - pep) - VALUES - (%s, 'leaf', %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], - 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, item_id 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 - 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], - 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 storeItems(self, item_data, publisher): - return self.dbpool.runInteraction(self._storeItems, item_data, publisher) - - def _storeItems(self, cursor, items_data, publisher): - self._checkNodeExists(cursor) - for item_data in items_data: - 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,)) - - for category in 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 "ORDER BY item_id DESC" - - 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("ORDER BY item_id ASC 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 item=%s""", - (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 618a92080812 -r dabee42494ac sat_pubsub/privilege.py --- a/sat_pubsub/privilege.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,309 +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) - jid_caps = self.caps_map.setdefault(from_jid_bare, {}) - if from_jid.resource not in jid_caps: - jid_caps[from_jid.resource] = disco_tuple - - 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 - } - - # 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, hash_ in online_resources.iteritems(): - notify = self.hash_map[hash_]['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 618a92080812 -r dabee42494ac sat_pubsub/schema.py --- a/sat_pubsub/schema.py Fri Jan 26 11:16:18 2018 +0100 +++ /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 618a92080812 -r dabee42494ac sat_pubsub/tap.py --- a/sat_pubsub/tap.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,184 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2018 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. - - -from twisted.application import service -from twisted.python import usage -from twisted.words.protocols.jabber.jid import JID - -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 sat_pubsub import __version__ -from sat_pubsub import const -from sat_pubsub import mam as pubsub_mam -from sat_pubsub.backend import BackendService -from sat_pubsub.schema import SchemaHandler -from sat_pubsub.privilege import PrivilegesHandler -from sat_pubsub.delegation import DelegationsHandler - - -class Options(usage.Options): - optParameters = [ - ('jid', None, 'pubsub', 'JID this component will be available at'), - ('secret', None, 'secret', 'Jabber server component secret'), - ('rhost', None, '127.0.0.1', 'Jabber server host'), - ('rport', None, '5347', 'Jabber server port'), - ('backend', None, 'pgsql', 'Choice of storage backend'), - ('dbuser', None, None, 'Database user (pgsql backend)'), - ('dbname', None, 'pubsub', 'Database name (pgsql backend)'), - ('dbpass', None, None, 'Database password (pgsql backend)'), - ('dbhost', None, None, 'Database host (pgsql backend)'), - ('dbport', None, None, 'Database port (pgsql backend)'), - ] - - optFlags = [ - ('verbose', 'v', 'Show traffic'), - ('hide-nodes', None, 'Hide all nodes for disco') - ] - - 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']) - - - -def makeService(config): - 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 = { - 'dbuser': 'user', - 'dbpass': 'password', - 'dbname': 'database', - 'dbhost': 'host', - 'dbport': '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) - 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["secret"]) - cs.setName('component') - cs.setServiceParent(s) - - cs.factory.maxDelay = 900 - - if config["verbose"]: - cs.logTraffic = True - - FallbackHandler().setHandlerParent(cs) - VersionHandler(u'SàT 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) - - sh = SchemaHandler() - sh.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 diff -r 618a92080812 -r dabee42494ac sat_pubsub/tap_http.py --- a/sat_pubsub/tap_http.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,135 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2003-2011 Ralph Meijer -# Copyright (c) 2012-2018 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. - - -from twisted.application import internet, strports -from twisted.conch import manhole, manhole_ssh -from twisted.cred import portal, checkers -from twisted.web import resource, server - -from sat_pubsub import gateway, tap -from sat_pubsub.gateway import RemoteSubscriptionService - -class Options(tap.Options): - optParameters = [ - ('webport', None, '8086', 'Web port'), - ] - - - -def getManholeFactory(namespace, **passwords): - def getManHole(_): - return manhole.Manhole(namespace) - - realm = manhole_ssh.TerminalRealm() - realm.chainedProtocolFactory.protocolFactory = getManHole - p = portal.Portal(realm) - p.registerChecker( - checkers.InMemoryUsernamePasswordDatabaseDontUse(**passwords)) - f = manhole_ssh.ConchFactory(p) - return f - - - -def makeService(config): - s = tap.makeService(config) - - bs = s.getServiceNamed('backend') - cs = s.getServiceNamed('component') - - # Set up XMPP service for subscribing to remote nodes - - if config['backend'] == 'pgsql': - from sat_pubsub.pgsql_storage import GatewayStorage - gst = GatewayStorage(bs.storage.dbpool) - elif config['backend'] == 'memory': - from sat_pubsub.memory_storage import GatewayStorage - gst = GatewayStorage() - - ss = RemoteSubscriptionService(config['jid'], gst) - ss.setHandlerParent(cs) - ss.startService() - - # Set up web service - - root = resource.Resource() - - # Set up resources that exposes the backend - root.putChild('create', gateway.CreateResource(bs, config['jid'], - config['jid'])) - root.putChild('delete', gateway.DeleteResource(bs, config['jid'], - config['jid'])) - root.putChild('publish', gateway.PublishResource(bs, config['jid'], - config['jid'])) - root.putChild('list', gateway.ListResource(bs)) - - # Set up resources for accessing remote pubsub nodes. - root.putChild('subscribe', gateway.RemoteSubscribeResource(ss)) - root.putChild('unsubscribe', gateway.RemoteUnsubscribeResource(ss)) - root.putChild('items', gateway.RemoteItemsResource(ss)) - - site = server.Site(root) - w = internet.TCPServer(int(config['webport']), site) - w.setServiceParent(s) - - # Set up a manhole - - namespace = {'service': s, - 'component': cs, - 'backend': bs, - 'root': root} - - f = getManholeFactory(namespace, admin='admin') - manholeService = strports.service('2222', f) - manholeService.setServiceParent(s) - - return s - diff -r 618a92080812 -r dabee42494ac sat_pubsub/test/__init__.py --- a/sat_pubsub/test/__init__.py Fri Jan 26 11:16:18 2018 +0100 +++ /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-2018 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 618a92080812 -r dabee42494ac sat_pubsub/test/test_backend.py --- a/sat_pubsub/test/test_backend.py Fri Jan 26 11:16:18 2018 +0100 +++ /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-2018 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 618a92080812 -r dabee42494ac sat_pubsub/test/test_gateway.py --- a/sat_pubsub/test/test_gateway.py Fri Jan 26 11:16:18 2018 +0100 +++ /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-2018 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 618a92080812 -r dabee42494ac sat_pubsub/test/test_storage.py --- a/sat_pubsub/test/test_storage.py Fri Jan 26 11:16:18 2018 +0100 +++ /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-2018 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 618a92080812 -r dabee42494ac src/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/__init__.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,64 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2018 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.2.0' + +# TODO: remove this when RSM and MAM are in wokkel +import wokkel +from sat_tmp.wokkel import pubsub as tmp_pubsub, rsm as tmp_rsm, mam as tmp_mam +wokkel.pubsub = tmp_pubsub +wokkel.rsm = tmp_rsm +wokkel.mam = tmp_mam diff -r 618a92080812 -r dabee42494ac src/backend.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/backend.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,1699 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- +# +# Copyright (c) 2012-2018 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 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 utility + +from wokkel import disco +from wokkel import data_form +from wokkel import rsm +from wokkel import iwokkel +from wokkel import pubsub + +from sat_pubsub import error +from sat_pubsub import iidavoll +from sat_pubsub import const +from sat_pubsub import container + +from copy import deepcopy + + +def _getAffiliation(node, entity): + d = node.getAffiliation(entity) + d.addCallback(lambda affiliation: (node, affiliation)) + return d + + +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"}, + } + + 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): + utility.EventDispatcher.__init__(self) + self.storage = storage + self._callbackList = [] + + 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 the itemIdentifiers correspond to items published + by the current 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 + 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 + 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 and affiliation != 'owner': + # we don't want a publisher to overwrite the item + # of an other publisher + yield self._checkOverwrite(node, [item['id'] for item in items if item.getAttribute('id')], 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) + + 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, options) + return d + + def _doSetNodeConfiguration(self, result, options): + node, affiliation = result + + if affiliation != 'owner': + 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, schema) + return d + + def _doSetNodeSchema(self, result, schema): + node, affiliation = result + + if affiliation != 'owner': + 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) + return d + + def _doGetAffiliationsOwner(self, result): + node, affiliation = result + + if affiliation != 'owner': + 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': + 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) + return d + + def _doGetSubscriptionsOwner(self, result): + node, affiliation = result + + if affiliation != 'owner': + 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': + 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())): + 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) + return d + + def _doPurge(self, result): + node, affiliation = result + persistItems = node.getConfiguration()[const.OPT_PERSIST_ITEMS] + + if affiliation != 'owner': + 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, redirectURI, pep, recipient) + return d + + def _doPreDelete(self, result, redirectURI, pep, recipient): + node, affiliation = result + + if affiliation != 'owner': + 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 + new_item = deepcopy(item) + if item_config: + new_item.addChild(item_config.toElement()) + return new_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 deepcopy items because different subscribers may receive + # different items (e.g. read restriction in schema) + items_data = deepcopy(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.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: + ext_data['rsm'] = request.rsm + try: + ext_data['pep'] = request.delegated + except AttributeError: + pass + 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) diff -r 618a92080812 -r dabee42494ac src/const.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/const.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,84 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2018 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' +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 618a92080812 -r dabee42494ac src/container.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/container.py Fri Mar 02 12:59:38 2018 +0100 @@ -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 618a92080812 -r dabee42494ac src/delegation.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/delegation.py Fri Mar 02 12:59:38 2018 +0100 @@ -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 618a92080812 -r dabee42494ac src/error.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/error.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,152 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/exceptions.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/exceptions.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,55 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/gateway.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/gateway.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,899 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/iidavoll.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/iidavoll.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,665 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/mam.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/mam.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,161 @@ +#!/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 + + 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 + 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 + 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) + + 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 618a92080812 -r dabee42494ac src/memory_storage.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/memory_storage.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,380 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/pgsql_storage.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/pgsql_storage.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,1340 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2018 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 = '4' +# 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, + }, + '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], + } + schema = row[9] + 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, + 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, + 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, + schema, + pep) + VALUES + (%s, 'leaf', %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], + 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, item_id 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 + 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], + 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 storeItems(self, item_data, publisher): + return self.dbpool.runInteraction(self._storeItems, item_data, publisher) + + def _storeItems(self, cursor, items_data, publisher): + self._checkNodeExists(cursor) + for item_data in items_data: + 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,)) + + for category in 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 "ORDER BY item_id DESC" + + 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("ORDER BY item_id ASC 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 item=%s""", + (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 618a92080812 -r dabee42494ac src/privilege.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/privilege.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,309 @@ +#!/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) + jid_caps = self.caps_map.setdefault(from_jid_bare, {}) + if from_jid.resource not in jid_caps: + jid_caps[from_jid.resource] = disco_tuple + + 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 + } + + # 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, hash_ in online_resources.iteritems(): + notify = self.hash_map[hash_]['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 618a92080812 -r dabee42494ac src/schema.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/schema.py Fri Mar 02 12:59:38 2018 +0100 @@ -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 618a92080812 -r dabee42494ac src/test/__init__.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/test/__init__.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,55 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/test/test_backend.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/test/test_backend.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,692 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/test/test_gateway.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/test/test_gateway.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,822 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/test/test_storage.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/test/test_storage.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,642 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2003-2011 Ralph Meijer +# Copyright (c) 2012-2018 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 618a92080812 -r dabee42494ac src/twisted/plugins/pubsub.py --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/twisted/plugins/pubsub.py Fri Mar 02 12:59:38 2018 +0100 @@ -0,0 +1,242 @@ +#!/usr/bin/python +#-*- coding: utf-8 -*- + +# Copyright (c) 2012-2018 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 sat_pubsub +from twisted.application.service import IServiceMaker +from twisted.application import service +from twisted.python import usage +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.backend import BackendService +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 + + +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 = [] + +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 (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) + 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) + + sh = SchemaHandler() + sh.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 618a92080812 -r dabee42494ac twisted/plugins/sat_pubsub.py --- a/twisted/plugins/sat_pubsub.py Fri Jan 26 11:16:18 2018 +0100 +++ /dev/null Thu Jan 01 00:00:00 1970 +0000 @@ -1,62 +0,0 @@ -#!/usr/bin/python -#-*- coding: utf-8 -*- - -# Copyright (c) 2012-2018 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. - - -try: - from twisted.application.service import ServiceMaker -except ImportError: - from twisted.scripts.mktap import _tapHelper as ServiceMaker - -SatPubsub = ServiceMaker( - u"Sàt Pubsub".encode('utf-8'), - "sat_pubsub.tap", - u"Salut à Toi Publish-Subscribe Service Component".encode('utf-8'), - "sat_pubsub")