001/**
002 *
003 * Copyright the original author or authors
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.jivesoftware.smackx.pubsub;
018
019import java.util.Collections;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.WeakHashMap;
024import java.util.concurrent.ConcurrentHashMap;
025import java.util.logging.Level;
026import java.util.logging.Logger;
027
028import org.jivesoftware.smack.Manager;
029import org.jivesoftware.smack.SmackException.NoResponseException;
030import org.jivesoftware.smack.SmackException.NotConnectedException;
031import org.jivesoftware.smack.XMPPConnection;
032import org.jivesoftware.smack.XMPPException.XMPPErrorException;
033import org.jivesoftware.smack.packet.EmptyResultIQ;
034import org.jivesoftware.smack.packet.ExtensionElement;
035import org.jivesoftware.smack.packet.IQ;
036import org.jivesoftware.smack.packet.IQ.Type;
037import org.jivesoftware.smack.packet.Stanza;
038import org.jivesoftware.smack.packet.XMPPError;
039import org.jivesoftware.smack.packet.XMPPError.Condition;
040
041import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
042import org.jivesoftware.smackx.disco.packet.DiscoverInfo;
043import org.jivesoftware.smackx.disco.packet.DiscoverItems;
044import org.jivesoftware.smackx.pubsub.PubSubException.NotALeafNodeException;
045import org.jivesoftware.smackx.pubsub.PubSubException.NotAPubSubNodeException;
046import org.jivesoftware.smackx.pubsub.packet.PubSub;
047import org.jivesoftware.smackx.pubsub.packet.PubSubNamespace;
048import org.jivesoftware.smackx.pubsub.util.NodeUtils;
049import org.jivesoftware.smackx.xdata.Form;
050import org.jivesoftware.smackx.xdata.FormField;
051
052import org.jxmpp.jid.BareJid;
053import org.jxmpp.jid.DomainBareJid;
054import org.jxmpp.jid.Jid;
055import org.jxmpp.jid.impl.JidCreate;
056import org.jxmpp.stringprep.XmppStringprepException;
057
058/**
059 * This is the starting point for access to the pubsub service.  It
060 * will provide access to general information about the service, as
061 * well as create or retrieve pubsub {@link LeafNode} instances.  These 
062 * instances provide the bulk of the functionality as defined in the 
063 * pubsub specification <a href="http://xmpp.org/extensions/xep-0060.html">XEP-0060</a>.
064 * 
065 * @author Robin Collier
066 */
067public final class PubSubManager extends Manager {
068
069    public static final String AUTO_CREATE_FEATURE = "http://jabber.org/protocol/pubsub#auto-create";
070
071    private static final Logger LOGGER = Logger.getLogger(PubSubManager.class.getName());
072    private static final Map<XMPPConnection, Map<BareJid, PubSubManager>> INSTANCES = new WeakHashMap<>();
073
074    /**
075     * The JID of the PubSub service this manager manages.
076     */
077    private final BareJid pubSubService;
078
079    /**
080     * A map of node IDs to Nodes, used to cache those Nodes. This does only cache the type of Node,
081     * i.e. {@link CollectionNode} or {@link LeafNode}.
082     */
083    private final Map<String, Node> nodeMap = new ConcurrentHashMap<>();
084
085    /**
086     * Get a PubSub manager for the default PubSub service of the connection.
087     * 
088     * @param connection
089     * @return the default PubSub manager.
090     */
091    public static PubSubManager getInstance(XMPPConnection connection) {
092        DomainBareJid pubSubService = null;
093        if (connection.isAuthenticated()) {
094            try {
095                pubSubService = getPubSubService(connection);
096            }
097            catch (NoResponseException | XMPPErrorException | NotConnectedException e) {
098                LOGGER.log(Level.WARNING, "Could not determine PubSub service", e);
099            }
100            catch (InterruptedException e) {
101                LOGGER.log(Level.FINE, "Interrupted while trying to determine PubSub service", e);
102            }
103        }
104        if (pubSubService == null) {
105            try {
106                // Perform an educated guess about what the PubSub service's domain bare JID may be
107                pubSubService = JidCreate.domainBareFrom("pubsub." + connection.getXMPPServiceDomain());
108            }
109            catch (XmppStringprepException e) {
110                throw new RuntimeException(e);
111            }
112        }
113        return getInstance(connection, pubSubService);
114    }
115
116    /**
117     * Get the PubSub manager for the given connection and PubSub service.
118     * 
119     * @param connection the XMPP connection.
120     * @param pubSubService the PubSub service.
121     * @return a PubSub manager for the connection and service.
122     */
123    public static synchronized PubSubManager getInstance(XMPPConnection connection, BareJid pubSubService) {
124        Map<BareJid, PubSubManager> managers = INSTANCES.get(connection);
125        if (managers == null) {
126            managers = new HashMap<>();
127            INSTANCES.put(connection, managers);
128        }
129        PubSubManager pubSubManager = managers.get(pubSubService);
130        if (pubSubManager == null) {
131            pubSubManager = new PubSubManager(connection, pubSubService);
132            managers.put(pubSubService, pubSubManager);
133        }
134        return pubSubManager;
135    }
136
137    /**
138     * Create a pubsub manager associated to the specified connection where
139     * the pubsub requests require a specific to address for packets.
140     * 
141     * @param connection The XMPP connection
142     * @param toAddress The pubsub specific to address (required for some servers)
143     */
144    PubSubManager(XMPPConnection connection, BareJid toAddress)
145    {
146        super(connection);
147        pubSubService = toAddress;
148    }
149
150    /**
151     * Creates an instant node, if supported.
152     * 
153     * @return The node that was created
154     * @throws XMPPErrorException 
155     * @throws NoResponseException 
156     * @throws NotConnectedException 
157     * @throws InterruptedException 
158     */
159    public LeafNode createNode() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
160    {
161        PubSub reply = sendPubsubPacket(Type.set, new NodeExtension(PubSubElementType.CREATE), null);
162        NodeExtension elem = reply.getExtension("create", PubSubNamespace.BASIC.getXmlns());
163
164        LeafNode newNode = new LeafNode(this, elem.getNode());
165        nodeMap.put(newNode.getId(), newNode);
166
167        return newNode;
168    }
169
170    /**
171     * Creates a node with default configuration.
172     * 
173     * @param nodeId The id of the node, which must be unique within the 
174     * pubsub service
175     * @return The node that was created
176     * @throws XMPPErrorException 
177     * @throws NoResponseException 
178     * @throws NotConnectedException 
179     * @throws InterruptedException 
180     */
181    public LeafNode createNode(String nodeId) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
182    {
183        return (LeafNode) createNode(nodeId, null);
184    }
185
186    /**
187     * Creates a node with specified configuration.
188     * 
189     * Note: This is the only way to create a collection node.
190     * 
191     * @param nodeId The name of the node, which must be unique within the 
192     * pubsub service
193     * @param config The configuration for the node
194     * @return The node that was created
195     * @throws XMPPErrorException 
196     * @throws NoResponseException 
197     * @throws NotConnectedException 
198     * @throws InterruptedException 
199     */
200    public Node createNode(String nodeId, Form config) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
201    {
202        PubSub request = PubSub.createPubsubPacket(pubSubService, Type.set, new NodeExtension(PubSubElementType.CREATE, nodeId), null);
203        boolean isLeafNode = true;
204
205        if (config != null)
206        {
207            request.addExtension(new FormNode(FormNodeType.CONFIGURE, config));
208            FormField nodeTypeField = config.getField(ConfigureNodeFields.node_type.getFieldName());
209
210            if (nodeTypeField != null)
211                isLeafNode = nodeTypeField.getValues().get(0).equals(NodeType.leaf.toString());
212        }
213
214        // Errors will cause exceptions in getReply, so it only returns
215        // on success.
216        sendPubsubPacket(request);
217        Node newNode = isLeafNode ? new LeafNode(this, nodeId) : new CollectionNode(this, nodeId);
218        nodeMap.put(newNode.getId(), newNode);
219
220        return newNode;
221    }
222
223    /**
224     * Retrieves the requested node, if it exists.  It will throw an 
225     * exception if it does not.
226     * 
227     * @param id - The unique id of the node
228     * @param <T> type of the node.
229     *
230     * @return the node
231     * @throws XMPPErrorException The node does not exist
232     * @throws NoResponseException if there was no response from the server.
233     * @throws NotConnectedException 
234     * @throws InterruptedException 
235     * @throws NotAPubSubNodeException 
236     */
237    public <T extends Node> T getNode(String id) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, NotAPubSubNodeException
238    {
239        Node node = nodeMap.get(id);
240
241        if (node == null)
242        {
243            DiscoverInfo info = new DiscoverInfo();
244            info.setTo(pubSubService);
245            info.setNode(id);
246
247            DiscoverInfo infoReply = connection().createStanzaCollectorAndSend(info).nextResultOrThrow();
248
249            if (infoReply.hasIdentity(PubSub.ELEMENT, "leaf")) {
250                node = new LeafNode(this, id);
251            }
252            else if (infoReply.hasIdentity(PubSub.ELEMENT, "collection")) {
253                node = new CollectionNode(this, id);
254            }
255            else {
256                throw new PubSubException.NotAPubSubNodeException(id, infoReply);
257            }
258            nodeMap.put(id, node);
259        }
260        @SuppressWarnings("unchecked")
261        T res = (T) node;
262        return res;
263    }
264
265    /**
266     * Try to get a leaf node and create one if it does not already exist.
267     *
268     * @param id The unique ID of the node.
269     * @return the leaf node.
270     * @throws NoResponseException
271     * @throws NotConnectedException
272     * @throws InterruptedException
273     * @throws XMPPErrorException
274     * @throws NotALeafNodeException in case the node already exists as collection node.
275     * @since 4.2.1
276     */
277    public LeafNode getOrCreateLeafNode(final String id)
278                    throws NoResponseException, NotConnectedException, InterruptedException, XMPPErrorException, NotALeafNodeException {
279        try {
280            return getNode(id);
281        }
282        catch (NotAPubSubNodeException e) {
283            return createNode(id);
284        }
285        catch (XMPPErrorException e1) {
286            if (e1.getXMPPError().getCondition() == Condition.item_not_found) {
287                try {
288                    return createNode(id);
289                }
290                catch (XMPPErrorException e2) {
291                    if (e2.getXMPPError().getCondition() == Condition.conflict) {
292                        // The node was created in the meantime, re-try getNode(). Note that this case should be rare.
293                        try {
294                            return getNode(id);
295                        }
296                        catch (NotAPubSubNodeException e) {
297                            // Should not happen
298                            throw new IllegalStateException(e);
299                        }
300                    }
301                    throw e2;
302                }
303            }
304            if (e1.getXMPPError().getCondition() == Condition.service_unavailable) {
305                // This could be caused by Prosody bug #805 (see https://prosody.im/issues/issue/805). Prosody does not
306                // answer to disco#info requests on the node ID, which makes it undecidable if a node is a leaf or
307                // collection node.
308                LOGGER.warning("The PubSub service " + pubSubService
309                        + " threw an DiscoInfoNodeAssertionError, trying workaround for Prosody bug #805 (https://prosody.im/issues/issue/805)");
310                return getOrCreateLeafNodeProsodyWorkaround(id);
311            }
312            throw e1;
313        }
314    }
315
316    /**
317     * Try to get a leaf node with the given node ID.
318     *
319     * @param id the node ID.
320     * @return the requested leaf node.
321     * @throws NotALeafNodeException in case the node exists but is a collection node.
322     * @throws NoResponseException
323     * @throws NotConnectedException
324     * @throws InterruptedException
325     * @throws XMPPErrorException
326     * @throws NotAPubSubNodeException 
327     * @since 4.2.1
328     */
329    public LeafNode getLeafNode(String id) throws NotALeafNodeException, NoResponseException, NotConnectedException,
330                    InterruptedException, XMPPErrorException, NotAPubSubNodeException {
331        Node node;
332        try {
333            node = getNode(id);
334        }
335        catch (XMPPErrorException e) {
336            if (e.getXMPPError().getCondition() == Condition.service_unavailable) {
337                // This could be caused by Prosody bug #805 (see https://prosody.im/issues/issue/805). Prosody does not
338                // answer to disco#info requests on the node ID, which makes it undecidable if a node is a leaf or
339                // collection node.
340                return getLeafNodeProsodyWorkaround(id);
341            }
342            throw e;
343        }
344
345        if (node instanceof LeafNode) {
346            return (LeafNode) node;
347        }
348
349        throw new PubSubException.NotALeafNodeException(id, pubSubService);
350    }
351
352    private LeafNode getLeafNodeProsodyWorkaround(final String id) throws NoResponseException, NotConnectedException,
353                    InterruptedException, NotALeafNodeException, XMPPErrorException {
354        LeafNode leafNode = new LeafNode(this, id);
355        try {
356            // Try to ensure that this is not a collection node by asking for one item form the node.
357            leafNode.getItems(1);
358        } catch (XMPPErrorException e) {
359            Condition condition = e.getXMPPError().getCondition();
360            if (condition == Condition.feature_not_implemented) {
361                // XEP-0060 § 6.5.9.5: Item retrieval not supported, e.g. because node is a collection node
362                throw new PubSubException.NotALeafNodeException(id, pubSubService);
363            }
364
365            throw e;
366        }
367
368        nodeMap.put(id, leafNode);
369
370        return leafNode;
371    }
372
373    private LeafNode getOrCreateLeafNodeProsodyWorkaround(final String id)
374                    throws XMPPErrorException, NoResponseException, NotConnectedException, InterruptedException, NotALeafNodeException {
375        try {
376            return createNode(id);
377        }
378        catch (XMPPErrorException e1) {
379            if (e1.getXMPPError().getCondition() == Condition.conflict) {
380                return getLeafNodeProsodyWorkaround(id);
381            }
382            throw e1;
383        }
384    }
385
386    /**
387     * Try to publish an item and, if the node with the given ID does not exists, auto-create the node.
388     * <p>
389     * Not every PubSub service supports automatic node creation. You can discover if this service supports it by using
390     * {@link #supportsAutomaticNodeCreation()}.
391     * </p>
392     *
393     * @param id The unique id of the node.
394     * @param item The item to publish.
395     * @param <I> type of the item.
396     *
397     * @return the LeafNode on which the item was published.
398     * @throws NoResponseException
399     * @throws XMPPErrorException
400     * @throws NotConnectedException
401     * @throws InterruptedException
402     * @since 4.2.1
403     */
404    public <I extends Item> LeafNode tryToPublishAndPossibleAutoCreate(String id, I item)
405                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
406        LeafNode leafNode = new LeafNode(this, id);
407        leafNode.send(item);
408
409        // If LeafNode.send() did not throw then we have successfully published an item and possible auto-created
410        // (XEP-0163 § 3., XEP-0060 § 7.1.4) the node. So we can put the node into the nodeMap.
411        nodeMap.put(id, leafNode);
412
413        return leafNode;
414    }
415
416    /**
417     * Get all the nodes that currently exist as a child of the specified
418     * collection node.  If the service does not support collection nodes
419     * then all nodes will be returned.
420     * 
421     * To retrieve contents of the root collection node (if it exists), 
422     * or there is no root collection node, pass null as the nodeId.
423     * 
424     * @param nodeId - The id of the collection node for which the child 
425     * nodes will be returned.  
426     * @return {@link DiscoverItems} representing the existing nodes
427     * @throws XMPPErrorException 
428     * @throws NoResponseException if there was no response from the server.
429     * @throws NotConnectedException 
430     * @throws InterruptedException 
431     */
432    public DiscoverItems discoverNodes(String nodeId) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
433    {
434        DiscoverItems items = new DiscoverItems();
435
436        if (nodeId != null)
437            items.setNode(nodeId);
438        items.setTo(pubSubService);
439        DiscoverItems nodeItems = connection().createStanzaCollectorAndSend(items).nextResultOrThrow();
440        return nodeItems;
441    }
442
443    /**
444     * Gets the subscriptions on the root node.
445     * 
446     * @return List of exceptions
447     * @throws XMPPErrorException 
448     * @throws NoResponseException 
449     * @throws NotConnectedException 
450     * @throws InterruptedException 
451     */
452    public List<Subscription> getSubscriptions() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
453    {
454        Stanza reply = sendPubsubPacket(Type.get, new NodeExtension(PubSubElementType.SUBSCRIPTIONS), null);
455        SubscriptionsExtension subElem = reply.getExtension(PubSubElementType.SUBSCRIPTIONS.getElementName(), PubSubElementType.SUBSCRIPTIONS.getNamespace().getXmlns());
456        return subElem.getSubscriptions();
457    }
458
459    /**
460     * Gets the affiliations on the root node.
461     * 
462     * @return List of affiliations
463     * @throws XMPPErrorException 
464     * @throws NoResponseException 
465     * @throws NotConnectedException 
466     * @throws InterruptedException 
467     * 
468     */
469    public List<Affiliation> getAffiliations() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
470    {
471        PubSub reply = sendPubsubPacket(Type.get, new NodeExtension(PubSubElementType.AFFILIATIONS), null);
472        AffiliationsExtension listElem = reply.getExtension(PubSubElementType.AFFILIATIONS);
473        return listElem.getAffiliations();
474    }
475
476    /**
477     * Delete the specified node.
478     * 
479     * @param nodeId
480     * @throws XMPPErrorException 
481     * @throws NoResponseException 
482     * @throws NotConnectedException 
483     * @throws InterruptedException 
484     */
485    public void deleteNode(String nodeId) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
486    {
487        sendPubsubPacket(Type.set, new NodeExtension(PubSubElementType.DELETE, nodeId), PubSubElementType.DELETE.getNamespace());
488        nodeMap.remove(nodeId);
489    }
490
491    /**
492     * Returns the default settings for Node configuration.
493     * 
494     * @return configuration form containing the default settings.
495     * @throws XMPPErrorException 
496     * @throws NoResponseException 
497     * @throws NotConnectedException 
498     * @throws InterruptedException 
499     */
500    public ConfigureForm getDefaultConfiguration() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
501    {
502        // Errors will cause exceptions in getReply, so it only returns
503        // on success.
504        PubSub reply = sendPubsubPacket(Type.get, new NodeExtension(PubSubElementType.DEFAULT), PubSubElementType.DEFAULT.getNamespace());
505        return NodeUtils.getFormFromPacket(reply, PubSubElementType.DEFAULT);
506    }
507
508    /**
509     * Get the JID of the PubSub service managed by this manager.
510     *
511     * @return the JID of the PubSub service.
512     */
513    public BareJid getServiceJid() {
514        return pubSubService;
515    }
516
517    /**
518     * Gets the supported features of the servers pubsub implementation
519     * as a standard {@link DiscoverInfo} instance.
520     * 
521     * @return The supported features
522     * @throws XMPPErrorException 
523     * @throws NoResponseException 
524     * @throws NotConnectedException 
525     * @throws InterruptedException 
526     */
527    public DiscoverInfo getSupportedFeatures() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException
528    {
529        ServiceDiscoveryManager mgr = ServiceDiscoveryManager.getInstanceFor(connection());
530        return mgr.discoverInfo(pubSubService);
531    }
532
533    /**
534     * Check if the PubSub service supports automatic node creation.
535     *
536     * @return true if the PubSub service supports automatic node creation.
537     * @throws NoResponseException
538     * @throws XMPPErrorException
539     * @throws NotConnectedException
540     * @throws InterruptedException
541     * @since 4.2.1
542     * @see <a href="https://xmpp.org/extensions/xep-0060.html#publisher-publish-autocreate">XEP-0060 § 7.1.4 Automatic Node Creation</a>
543     */
544    public boolean supportsAutomaticNodeCreation()
545                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
546        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection());
547        return sdm.supportsFeature(pubSubService, AUTO_CREATE_FEATURE);
548    }
549
550    /**
551     * Check if it is possible to create PubSub nodes on this service. It could be possible that the
552     * PubSub service allows only certain XMPP entities (clients) to create nodes and publish items
553     * to them.
554     * <p>
555     * Note that since XEP-60 does not provide an API to determine if an XMPP entity is allowed to
556     * create nodes, therefore this method creates an instant node calling {@link #createNode()} to
557     * determine if it is possible to create nodes.
558     * </p>
559     *
560     * @return <code>true</code> if it is possible to create nodes, <code>false</code> otherwise.
561     * @throws NoResponseException
562     * @throws NotConnectedException
563     * @throws InterruptedException
564     * @throws XMPPErrorException
565     */
566    public boolean canCreateNodesAndPublishItems() throws NoResponseException, NotConnectedException, InterruptedException, XMPPErrorException {
567        LeafNode leafNode = null;
568        try {
569            leafNode = createNode();
570        }
571        catch (XMPPErrorException e) {
572            if (e.getXMPPError().getCondition() == XMPPError.Condition.forbidden) {
573                return false;
574            }
575            throw e;
576        } finally {
577            if (leafNode != null) {
578                deleteNode(leafNode.getId());
579            }
580        }
581        return true;
582    }
583
584    private PubSub sendPubsubPacket(Type type, ExtensionElement ext, PubSubNamespace ns)
585                    throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
586        return sendPubsubPacket(pubSubService, type, Collections.singletonList(ext), ns);
587    }
588
589    XMPPConnection getConnection() {
590        return connection();
591    }
592
593    PubSub sendPubsubPacket(Jid to, Type type, List<ExtensionElement> extList, PubSubNamespace ns)
594                    throws NoResponseException, XMPPErrorException, NotConnectedException,
595                    InterruptedException {
596// CHECKSTYLE:OFF
597        PubSub pubSub = new PubSub(to, type, ns);
598        for (ExtensionElement pe : extList) {
599            pubSub.addExtension(pe);
600        }
601// CHECKSTYLE:ON
602        return sendPubsubPacket(pubSub);
603    }
604
605    PubSub sendPubsubPacket(PubSub packet) throws NoResponseException, XMPPErrorException,
606                    NotConnectedException, InterruptedException {
607        IQ resultIQ = connection().createStanzaCollectorAndSend(packet).nextResultOrThrow();
608        if (resultIQ instanceof EmptyResultIQ) {
609            return null;
610        }
611        return (PubSub) resultIQ;
612    }
613
614    /**
615     * Get the "default" PubSub service for a given XMPP connection. The default PubSub service is
616     * simply an arbitrary XMPP service with the PubSub feature and an identity of category "pubsub"
617     * and type "service".
618     * 
619     * @param connection
620     * @return the default PubSub service or <code>null</code>.
621     * @throws NoResponseException
622     * @throws XMPPErrorException
623     * @throws NotConnectedException
624     * @throws InterruptedException
625     * @see <a href="http://xmpp.org/extensions/xep-0060.html#entity-features">XEP-60 § 5.1 Discover
626     *      Features</a>
627     */
628    public static DomainBareJid getPubSubService(XMPPConnection connection)
629                    throws NoResponseException, XMPPErrorException, NotConnectedException,
630                    InterruptedException {
631        return ServiceDiscoveryManager.getInstanceFor(connection).findService(PubSub.NAMESPACE,
632                        true, "pubsub", "service");
633    }
634}