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.StanzaError; 039import org.jivesoftware.smack.packet.StanzaError.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 super(connection); 146 pubSubService = toAddress; 147 } 148 149 /** 150 * Creates an instant node, if supported. 151 * 152 * @return The node that was created 153 * @throws XMPPErrorException 154 * @throws NoResponseException 155 * @throws NotConnectedException 156 * @throws InterruptedException 157 */ 158 public LeafNode createNode() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 159 PubSub reply = sendPubsubPacket(Type.set, new NodeExtension(PubSubElementType.CREATE), null); 160 NodeExtension elem = reply.getExtension("create", PubSubNamespace.basic.getXmlns()); 161 162 LeafNode newNode = new LeafNode(this, elem.getNode()); 163 nodeMap.put(newNode.getId(), newNode); 164 165 return newNode; 166 } 167 168 /** 169 * Creates a node with default configuration. 170 * 171 * @param nodeId The id of the node, which must be unique within the 172 * pubsub service 173 * @return The node that was created 174 * @throws XMPPErrorException 175 * @throws NoResponseException 176 * @throws NotConnectedException 177 * @throws InterruptedException 178 */ 179 public LeafNode createNode(String nodeId) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 180 return (LeafNode) createNode(nodeId, null); 181 } 182 183 /** 184 * Creates a node with specified configuration. 185 * 186 * Note: This is the only way to create a collection node. 187 * 188 * @param nodeId The name of the node, which must be unique within the 189 * pubsub service 190 * @param config The configuration for the node 191 * @return The node that was created 192 * @throws XMPPErrorException 193 * @throws NoResponseException 194 * @throws NotConnectedException 195 * @throws InterruptedException 196 */ 197 public Node createNode(String nodeId, Form config) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 198 PubSub request = PubSub.createPubsubPacket(pubSubService, Type.set, new NodeExtension(PubSubElementType.CREATE, nodeId)); 199 boolean isLeafNode = true; 200 201 if (config != null) { 202 request.addExtension(new FormNode(FormNodeType.CONFIGURE, config)); 203 FormField nodeTypeField = config.getField(ConfigureNodeFields.node_type.getFieldName()); 204 205 if (nodeTypeField != null) 206 isLeafNode = nodeTypeField.getValues().get(0).equals(NodeType.leaf.toString()); 207 } 208 209 // Errors will cause exceptions in getReply, so it only returns 210 // on success. 211 sendPubsubPacket(request); 212 Node newNode = isLeafNode ? new LeafNode(this, nodeId) : new CollectionNode(this, nodeId); 213 nodeMap.put(newNode.getId(), newNode); 214 215 return newNode; 216 } 217 218 /** 219 * Retrieves the requested node, if it exists. It will throw an 220 * exception if it does not. 221 * 222 * @param id - The unique id of the node 223 * @param <T> type of the node. 224 * 225 * @return the node 226 * @throws XMPPErrorException The node does not exist 227 * @throws NoResponseException if there was no response from the server. 228 * @throws NotConnectedException 229 * @throws InterruptedException 230 * @throws NotAPubSubNodeException 231 */ 232 public <T extends Node> T getNode(String id) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException, NotAPubSubNodeException { 233 Node node = nodeMap.get(id); 234 235 if (node == null) { 236 DiscoverInfo info = new DiscoverInfo(); 237 info.setTo(pubSubService); 238 info.setNode(id); 239 240 DiscoverInfo infoReply = connection().createStanzaCollectorAndSend(info).nextResultOrThrow(); 241 242 if (infoReply.hasIdentity(PubSub.ELEMENT, "leaf")) { 243 node = new LeafNode(this, id); 244 } 245 else if (infoReply.hasIdentity(PubSub.ELEMENT, "collection")) { 246 node = new CollectionNode(this, id); 247 } 248 else { 249 throw new PubSubException.NotAPubSubNodeException(id, infoReply); 250 } 251 nodeMap.put(id, node); 252 } 253 @SuppressWarnings("unchecked") 254 T res = (T) node; 255 return res; 256 } 257 258 /** 259 * Try to get a leaf node and create one if it does not already exist. 260 * 261 * @param id The unique ID of the node. 262 * @return the leaf node. 263 * @throws NoResponseException 264 * @throws NotConnectedException 265 * @throws InterruptedException 266 * @throws XMPPErrorException 267 * @throws NotALeafNodeException in case the node already exists as collection node. 268 * @since 4.2.1 269 */ 270 public LeafNode getOrCreateLeafNode(final String id) 271 throws NoResponseException, NotConnectedException, InterruptedException, XMPPErrorException, NotALeafNodeException { 272 try { 273 return getNode(id); 274 } 275 catch (NotAPubSubNodeException e) { 276 return createNode(id); 277 } 278 catch (XMPPErrorException e1) { 279 if (e1.getStanzaError().getCondition() == Condition.item_not_found) { 280 try { 281 return createNode(id); 282 } 283 catch (XMPPErrorException e2) { 284 if (e2.getStanzaError().getCondition() == Condition.conflict) { 285 // The node was created in the meantime, re-try getNode(). Note that this case should be rare. 286 try { 287 return getNode(id); 288 } 289 catch (NotAPubSubNodeException e) { 290 // Should not happen 291 throw new IllegalStateException(e); 292 } 293 } 294 throw e2; 295 } 296 } 297 if (e1.getStanzaError().getCondition() == Condition.service_unavailable) { 298 // This could be caused by Prosody bug #805 (see https://prosody.im/issues/issue/805). Prosody does not 299 // answer to disco#info requests on the node ID, which makes it undecidable if a node is a leaf or 300 // collection node. 301 LOGGER.warning("The PubSub service " + pubSubService 302 + " threw an DiscoInfoNodeAssertionError, trying workaround for Prosody bug #805 (https://prosody.im/issues/issue/805)"); 303 return getOrCreateLeafNodeProsodyWorkaround(id); 304 } 305 throw e1; 306 } 307 } 308 309 /** 310 * Try to get a leaf node with the given node ID. 311 * 312 * @param id the node ID. 313 * @return the requested leaf node. 314 * @throws NotALeafNodeException in case the node exists but is a collection node. 315 * @throws NoResponseException 316 * @throws NotConnectedException 317 * @throws InterruptedException 318 * @throws XMPPErrorException 319 * @throws NotAPubSubNodeException 320 * @since 4.2.1 321 */ 322 public LeafNode getLeafNode(String id) throws NotALeafNodeException, NoResponseException, NotConnectedException, 323 InterruptedException, XMPPErrorException, NotAPubSubNodeException { 324 Node node; 325 try { 326 node = getNode(id); 327 } 328 catch (XMPPErrorException e) { 329 if (e.getStanzaError().getCondition() == Condition.service_unavailable) { 330 // This could be caused by Prosody bug #805 (see https://prosody.im/issues/issue/805). Prosody does not 331 // answer to disco#info requests on the node ID, which makes it undecidable if a node is a leaf or 332 // collection node. 333 return getLeafNodeProsodyWorkaround(id); 334 } 335 throw e; 336 } 337 338 if (node instanceof LeafNode) { 339 return (LeafNode) node; 340 } 341 342 throw new PubSubException.NotALeafNodeException(id, pubSubService); 343 } 344 345 private LeafNode getLeafNodeProsodyWorkaround(final String id) throws NoResponseException, NotConnectedException, 346 InterruptedException, NotALeafNodeException, XMPPErrorException { 347 LeafNode leafNode = new LeafNode(this, id); 348 try { 349 // Try to ensure that this is not a collection node by asking for one item form the node. 350 leafNode.getItems(1); 351 } catch (XMPPErrorException e) { 352 Condition condition = e.getStanzaError().getCondition(); 353 if (condition == Condition.feature_not_implemented) { 354 // XEP-0060 § 6.5.9.5: Item retrieval not supported, e.g. because node is a collection node 355 throw new PubSubException.NotALeafNodeException(id, pubSubService); 356 } 357 358 throw e; 359 } 360 361 nodeMap.put(id, leafNode); 362 363 return leafNode; 364 } 365 366 private LeafNode getOrCreateLeafNodeProsodyWorkaround(final String id) 367 throws XMPPErrorException, NoResponseException, NotConnectedException, InterruptedException, NotALeafNodeException { 368 try { 369 return createNode(id); 370 } 371 catch (XMPPErrorException e1) { 372 if (e1.getStanzaError().getCondition() == Condition.conflict) { 373 return getLeafNodeProsodyWorkaround(id); 374 } 375 throw e1; 376 } 377 } 378 379 /** 380 * Try to publish an item and, if the node with the given ID does not exists, auto-create the node. 381 * <p> 382 * Not every PubSub service supports automatic node creation. You can discover if this service supports it by using 383 * {@link #supportsAutomaticNodeCreation()}. 384 * </p> 385 * 386 * @param id The unique id of the node. 387 * @param item The item to publish. 388 * @param <I> type of the item. 389 * 390 * @return the LeafNode on which the item was published. 391 * @throws NoResponseException 392 * @throws XMPPErrorException 393 * @throws NotConnectedException 394 * @throws InterruptedException 395 * @since 4.2.1 396 */ 397 public <I extends Item> LeafNode tryToPublishAndPossibleAutoCreate(String id, I item) 398 throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 399 LeafNode leafNode = new LeafNode(this, id); 400 leafNode.publish(item); 401 402 // If LeafNode.publish() did not throw then we have successfully published an item and possible auto-created 403 // (XEP-0163 § 3., XEP-0060 § 7.1.4) the node. So we can put the node into the nodeMap. 404 nodeMap.put(id, leafNode); 405 406 return leafNode; 407 } 408 409 /** 410 * Get all the nodes that currently exist as a child of the specified 411 * collection node. If the service does not support collection nodes 412 * then all nodes will be returned. 413 * 414 * To retrieve contents of the root collection node (if it exists), 415 * or there is no root collection node, pass null as the nodeId. 416 * 417 * @param nodeId - The id of the collection node for which the child 418 * nodes will be returned. 419 * @return {@link DiscoverItems} representing the existing nodes 420 * @throws XMPPErrorException 421 * @throws NoResponseException if there was no response from the server. 422 * @throws NotConnectedException 423 * @throws InterruptedException 424 */ 425 public DiscoverItems discoverNodes(String nodeId) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 426 DiscoverItems items = new DiscoverItems(); 427 428 if (nodeId != null) 429 items.setNode(nodeId); 430 items.setTo(pubSubService); 431 DiscoverItems nodeItems = connection().createStanzaCollectorAndSend(items).nextResultOrThrow(); 432 return nodeItems; 433 } 434 435 /** 436 * Gets the subscriptions on the root node. 437 * 438 * @return List of exceptions 439 * @throws XMPPErrorException 440 * @throws NoResponseException 441 * @throws NotConnectedException 442 * @throws InterruptedException 443 */ 444 public List<Subscription> getSubscriptions() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 445 Stanza reply = sendPubsubPacket(Type.get, new NodeExtension(PubSubElementType.SUBSCRIPTIONS), null); 446 SubscriptionsExtension subElem = reply.getExtension(PubSubElementType.SUBSCRIPTIONS.getElementName(), PubSubElementType.SUBSCRIPTIONS.getNamespace().getXmlns()); 447 return subElem.getSubscriptions(); 448 } 449 450 /** 451 * Gets the affiliations on the root node. 452 * 453 * @return List of affiliations 454 * @throws XMPPErrorException 455 * @throws NoResponseException 456 * @throws NotConnectedException 457 * @throws InterruptedException 458 * 459 */ 460 public List<Affiliation> getAffiliations() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 461 PubSub reply = sendPubsubPacket(Type.get, new NodeExtension(PubSubElementType.AFFILIATIONS), null); 462 AffiliationsExtension listElem = reply.getExtension(PubSubElementType.AFFILIATIONS); 463 return listElem.getAffiliations(); 464 } 465 466 /** 467 * Delete the specified node. 468 * 469 * @param nodeId 470 * @throws XMPPErrorException 471 * @throws NoResponseException 472 * @throws NotConnectedException 473 * @throws InterruptedException 474 */ 475 public void deleteNode(String nodeId) throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 476 sendPubsubPacket(Type.set, new NodeExtension(PubSubElementType.DELETE, nodeId), PubSubElementType.DELETE.getNamespace()); 477 nodeMap.remove(nodeId); 478 } 479 480 /** 481 * Returns the default settings for Node configuration. 482 * 483 * @return configuration form containing the default settings. 484 * @throws XMPPErrorException 485 * @throws NoResponseException 486 * @throws NotConnectedException 487 * @throws InterruptedException 488 */ 489 public ConfigureForm getDefaultConfiguration() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 490 // Errors will cause exceptions in getReply, so it only returns 491 // on success. 492 PubSub reply = sendPubsubPacket(Type.get, new NodeExtension(PubSubElementType.DEFAULT), PubSubElementType.DEFAULT.getNamespace()); 493 return NodeUtils.getFormFromPacket(reply, PubSubElementType.DEFAULT); 494 } 495 496 /** 497 * Get the JID of the PubSub service managed by this manager. 498 * 499 * @return the JID of the PubSub service. 500 */ 501 public BareJid getServiceJid() { 502 return pubSubService; 503 } 504 505 /** 506 * Gets the supported features of the servers pubsub implementation 507 * as a standard {@link DiscoverInfo} instance. 508 * 509 * @return The supported features 510 * @throws XMPPErrorException 511 * @throws NoResponseException 512 * @throws NotConnectedException 513 * @throws InterruptedException 514 */ 515 public DiscoverInfo getSupportedFeatures() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 516 ServiceDiscoveryManager mgr = ServiceDiscoveryManager.getInstanceFor(connection()); 517 return mgr.discoverInfo(pubSubService); 518 } 519 520 /** 521 * Check if the PubSub service supports automatic node creation. 522 * 523 * @return true if the PubSub service supports automatic node creation. 524 * @throws NoResponseException 525 * @throws XMPPErrorException 526 * @throws NotConnectedException 527 * @throws InterruptedException 528 * @since 4.2.1 529 * @see <a href="https://xmpp.org/extensions/xep-0060.html#publisher-publish-autocreate">XEP-0060 § 7.1.4 Automatic Node Creation</a> 530 */ 531 public boolean supportsAutomaticNodeCreation() 532 throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 533 ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection()); 534 return sdm.supportsFeature(pubSubService, AUTO_CREATE_FEATURE); 535 } 536 537 /** 538 * Check if it is possible to create PubSub nodes on this service. It could be possible that the 539 * PubSub service allows only certain XMPP entities (clients) to create nodes and publish items 540 * to them. 541 * <p> 542 * Note that since XEP-60 does not provide an API to determine if an XMPP entity is allowed to 543 * create nodes, therefore this method creates an instant node calling {@link #createNode()} to 544 * determine if it is possible to create nodes. 545 * </p> 546 * 547 * @return <code>true</code> if it is possible to create nodes, <code>false</code> otherwise. 548 * @throws NoResponseException 549 * @throws NotConnectedException 550 * @throws InterruptedException 551 * @throws XMPPErrorException 552 */ 553 public boolean canCreateNodesAndPublishItems() throws NoResponseException, NotConnectedException, InterruptedException, XMPPErrorException { 554 LeafNode leafNode = null; 555 try { 556 leafNode = createNode(); 557 } 558 catch (XMPPErrorException e) { 559 if (e.getStanzaError().getCondition() == StanzaError.Condition.forbidden) { 560 return false; 561 } 562 throw e; 563 } finally { 564 if (leafNode != null) { 565 deleteNode(leafNode.getId()); 566 } 567 } 568 return true; 569 } 570 571 private PubSub sendPubsubPacket(Type type, ExtensionElement ext, PubSubNamespace ns) 572 throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException { 573 return sendPubsubPacket(pubSubService, type, Collections.singletonList(ext), ns); 574 } 575 576 XMPPConnection getConnection() { 577 return connection(); 578 } 579 580 PubSub sendPubsubPacket(Jid to, Type type, List<ExtensionElement> extList, PubSubNamespace ns) 581 throws NoResponseException, XMPPErrorException, NotConnectedException, 582 InterruptedException { 583// CHECKSTYLE:OFF 584 PubSub pubSub = new PubSub(to, type, ns); 585 for (ExtensionElement pe : extList) { 586 pubSub.addExtension(pe); 587 } 588// CHECKSTYLE:ON 589 return sendPubsubPacket(pubSub); 590 } 591 592 PubSub sendPubsubPacket(PubSub packet) throws NoResponseException, XMPPErrorException, 593 NotConnectedException, InterruptedException { 594 IQ resultIQ = connection().createStanzaCollectorAndSend(packet).nextResultOrThrow(); 595 if (resultIQ instanceof EmptyResultIQ) { 596 return null; 597 } 598 return (PubSub) resultIQ; 599 } 600 601 /** 602 * Get the "default" PubSub service for a given XMPP connection. The default PubSub service is 603 * simply an arbitrary XMPP service with the PubSub feature and an identity of category "pubsub" 604 * and type "service". 605 * 606 * @param connection 607 * @return the default PubSub service or <code>null</code>. 608 * @throws NoResponseException 609 * @throws XMPPErrorException 610 * @throws NotConnectedException 611 * @throws InterruptedException 612 * @see <a href="http://xmpp.org/extensions/xep-0060.html#entity-features">XEP-60 § 5.1 Discover 613 * Features</a> 614 */ 615 public static DomainBareJid getPubSubService(XMPPConnection connection) 616 throws NoResponseException, XMPPErrorException, NotConnectedException, 617 InterruptedException { 618 return ServiceDiscoveryManager.getInstanceFor(connection).findService(PubSub.NAMESPACE, 619 true, "pubsub", "service"); 620 } 621}