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}