changeset 369:dabee42494ac

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
author Goffi <goffi@goffi.org>
date Fri, 02 Mar 2018 12:59:38 +0100
parents 618a92080812
children bfbc84057c0e
files sat_pubsub/__init__.py sat_pubsub/backend.py sat_pubsub/const.py sat_pubsub/container.py sat_pubsub/delegation.py sat_pubsub/error.py sat_pubsub/exceptions.py sat_pubsub/gateway.py sat_pubsub/iidavoll.py sat_pubsub/mam.py sat_pubsub/memory_storage.py sat_pubsub/pgsql_storage.py sat_pubsub/privilege.py sat_pubsub/schema.py sat_pubsub/tap.py sat_pubsub/tap_http.py sat_pubsub/test/__init__.py sat_pubsub/test/test_backend.py sat_pubsub/test/test_gateway.py sat_pubsub/test/test_storage.py src/__init__.py src/backend.py src/const.py src/container.py src/delegation.py src/error.py src/exceptions.py src/gateway.py src/iidavoll.py src/mam.py src/memory_storage.py src/pgsql_storage.py src/privilege.py src/schema.py src/test/__init__.py src/test/test_backend.py src/test/test_gateway.py src/test/test_storage.py src/twisted/plugins/pubsub.py twisted/plugins/sat_pubsub.py
diffstat 40 files changed, 8682 insertions(+), 8821 deletions(-) [+]
line wrap: on
line diff
--- 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 <http://www.gnu.org/licenses/>.
-# --
-#
-# 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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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<http://www.xmpp.org/extensions/xep-0060.html>} 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)
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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'
--- 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 <http://www.gnu.org/licenses/>.
-
-
-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
--- 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 <http://www.gnu.org/licenses/>.
-
-# ---
-
-# 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 <message/> 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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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<twisted.internet.defer.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<twisted.words.xish.domish.Element>}s.
-        @type items: C{list}
-        @return: The notification list as tuples of
-                 (L{JID<twisted.words.protocols.jabber.jid.JID>},
-                  C{list} of L{Subscription<wokkel.pubsub.Subscription>},
-                  C{list} of L{Element<twisted.words.xish.domish.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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{<item/>} 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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.internet.defer.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<twisted.words.protocols.jabber.jid.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<twisted.internet.defer.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<twisted.words.protocols.jabber.jid.JID>}
-        @param nodeIdentifier: The identifier of the publish-subscribe node.
-        @type nodeIdentifier: C{unicode}.
-        @rtype: L{Deferred<twisted.internet.defer.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<twisted.words.protocols.jabber.jid.JID>}
-        @param nodeIdentifier: The identifier of the publish-subscribe node.
-        @type nodeIdentifier: C{unicode}.
-        @returns: Deferred that fires with a boolean.
-        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
-        """
--- 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 <http://www.gnu.org/licenses/>.
-
-"""
-XMPP Message Archive Management protocol.
-
-This protocol is specified in
-U{XEP-0313<http://xmpp.org/extensions/xep-0313.html>}.
-"""
-
-
-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<wokkel.mam.MAMRequest>}
-
-        @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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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()
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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<twisted.words.xish.domish.Element>}
-    @ivar publisher: The entity that published the item.
-    @type publisher: L{JID<twisted.words.protocols.jabber.jid.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)
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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)
--- 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 <http://www.gnu.org/licenses/>.
-
-# ---
-
-# 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 <message/> 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)
--- 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 <http://www.gnu.org/licenses/>.
-
-# ---
-
-# 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 []
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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
-
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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}.
-"""
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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 = "<item xmlns='%s' id='1'/>" % 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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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'<root><child/></root>')
-
-        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
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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"
--- /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 <http://www.gnu.org/licenses/>.
+# --
+#
+# 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
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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<http://www.xmpp.org/extensions/xep-0060.html>} 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)
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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'
--- /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 <http://www.gnu.org/licenses/>.
+
+
+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
--- /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 <http://www.gnu.org/licenses/>.
+
+# ---
+
+# 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 <message/> 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
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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<twisted.internet.defer.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<twisted.words.xish.domish.Element>}s.
+        @type items: C{list}
+        @return: The notification list as tuples of
+                 (L{JID<twisted.words.protocols.jabber.jid.JID>},
+                  C{list} of L{Subscription<wokkel.pubsub.Subscription>},
+                  C{list} of L{Element<twisted.words.xish.domish.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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{<item/>} 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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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<twisted.internet.defer.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<twisted.words.protocols.jabber.jid.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<twisted.internet.defer.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<twisted.words.protocols.jabber.jid.JID>}
+        @param nodeIdentifier: The identifier of the publish-subscribe node.
+        @type nodeIdentifier: C{unicode}.
+        @rtype: L{Deferred<twisted.internet.defer.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<twisted.words.protocols.jabber.jid.JID>}
+        @param nodeIdentifier: The identifier of the publish-subscribe node.
+        @type nodeIdentifier: C{unicode}.
+        @returns: Deferred that fires with a boolean.
+        @rtype: L{Deferred<twisted.internet.defer.Deferred>}
+        """
--- /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 <http://www.gnu.org/licenses/>.
+
+"""
+XMPP Message Archive Management protocol.
+
+This protocol is specified in
+U{XEP-0313<http://xmpp.org/extensions/xep-0313.html>}.
+"""
+
+
+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<wokkel.mam.MAMRequest>}
+
+        @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<twisted.words.protocols.jabber.jid.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<twisted.words.protocols.jabber.jid.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()
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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<twisted.words.xish.domish.Element>}
+    @ivar publisher: The entity that published the item.
+    @type publisher: L{JID<twisted.words.protocols.jabber.jid.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)
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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)
--- /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 <http://www.gnu.org/licenses/>.
+
+# ---
+
+# 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 <message/> 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)
--- /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 <http://www.gnu.org/licenses/>.
+
+# ---
+
+# 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 []
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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}.
+"""
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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 = "<item xmlns='%s' id='1'/>" % 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
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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'<root><child/></root>')
+
+        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
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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"
--- /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 <http://www.gnu.org/licenses/>.
+# --
+
+# 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()
--- 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 <http://www.gnu.org/licenses/>.
-# --
-
-# 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")