001/** 002 * 003 * Copyright © 2009 Jonas Ådahl, 2011-2014 Florian Schmaus 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.caps; 018 019import org.jivesoftware.smack.AbstractConnectionListener; 020import org.jivesoftware.smack.SmackException.NoResponseException; 021import org.jivesoftware.smack.SmackException.NotConnectedException; 022import org.jivesoftware.smack.XMPPConnection; 023import org.jivesoftware.smack.ConnectionCreationListener; 024import org.jivesoftware.smack.Manager; 025import org.jivesoftware.smack.PacketInterceptor; 026import org.jivesoftware.smack.PacketListener; 027import org.jivesoftware.smack.XMPPException.XMPPErrorException; 028import org.jivesoftware.smack.packet.IQ; 029import org.jivesoftware.smack.packet.Packet; 030import org.jivesoftware.smack.packet.PacketExtension; 031import org.jivesoftware.smack.packet.Presence; 032import org.jivesoftware.smack.filter.NotFilter; 033import org.jivesoftware.smack.filter.PacketFilter; 034import org.jivesoftware.smack.filter.AndFilter; 035import org.jivesoftware.smack.filter.PacketTypeFilter; 036import org.jivesoftware.smack.filter.PacketExtensionFilter; 037import org.jivesoftware.smack.util.Base64; 038import org.jivesoftware.smack.util.Cache; 039import org.jivesoftware.smackx.caps.cache.EntityCapsPersistentCache; 040import org.jivesoftware.smackx.caps.packet.CapsExtension; 041import org.jivesoftware.smackx.disco.NodeInformationProvider; 042import org.jivesoftware.smackx.disco.ServiceDiscoveryManager; 043import org.jivesoftware.smackx.disco.packet.DiscoverInfo; 044import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Feature; 045import org.jivesoftware.smackx.disco.packet.DiscoverInfo.Identity; 046import org.jivesoftware.smackx.disco.packet.DiscoverItems.Item; 047import org.jivesoftware.smackx.xdata.FormField; 048import org.jivesoftware.smackx.xdata.packet.DataForm; 049 050import java.util.Collections; 051import java.util.Comparator; 052import java.util.HashMap; 053import java.util.LinkedList; 054import java.util.List; 055import java.util.Locale; 056import java.util.Map; 057import java.util.Queue; 058import java.util.SortedSet; 059import java.util.TreeSet; 060import java.util.WeakHashMap; 061import java.util.concurrent.ConcurrentLinkedQueue; 062import java.util.logging.Level; 063import java.util.logging.Logger; 064import java.io.IOException; 065import java.security.MessageDigest; 066import java.security.NoSuchAlgorithmException; 067 068/** 069 * Keeps track of entity capabilities. 070 * 071 * @author Florian Schmaus 072 * @see <a href="http://www.xmpp.org/extensions/xep-0115.html">XEP-0115: Entity Capabilities</a> 073 */ 074public class EntityCapsManager extends Manager { 075 private static final Logger LOGGER = Logger.getLogger(EntityCapsManager.class.getName()); 076 077 public static final String NAMESPACE = "http://jabber.org/protocol/caps"; 078 public static final String ELEMENT = "c"; 079 080 private static final Map<String, MessageDigest> SUPPORTED_HASHES = new HashMap<String, MessageDigest>(); 081 private static String DEFAULT_ENTITY_NODE = "http://www.igniterealtime.org/projects/smack"; 082 083 protected static EntityCapsPersistentCache persistentCache; 084 085 private static boolean autoEnableEntityCaps = true; 086 087 private static Map<XMPPConnection, EntityCapsManager> instances = Collections 088 .synchronizedMap(new WeakHashMap<XMPPConnection, EntityCapsManager>()); 089 090 private static final PacketFilter PRESENCES_WITH_CAPS = new AndFilter(new PacketTypeFilter(Presence.class), new PacketExtensionFilter( 091 ELEMENT, NAMESPACE)); 092 private static final PacketFilter PRESENCES_WITHOUT_CAPS = new AndFilter(new PacketTypeFilter(Presence.class), new NotFilter(new PacketExtensionFilter( 093 ELEMENT, NAMESPACE))); 094 private static final PacketFilter PRESENCES = new PacketTypeFilter(Presence.class); 095 096 /** 097 * Map of (node + '#" + hash algorithm) to DiscoverInfo data 098 */ 099 protected static Map<String, DiscoverInfo> caps = new Cache<String, DiscoverInfo>(1000, -1); 100 101 /** 102 * Map of Full JID -> DiscoverInfo/null. In case of c2s connection the 103 * key is formed as user@server/resource (resource is required) In case of 104 * link-local connection the key is formed as user@host (no resource) In 105 * case of a server or component the key is formed as domain 106 */ 107 protected static Map<String, NodeVerHash> jidCaps = new Cache<String, NodeVerHash>(10000, -1); 108 109 static { 110 XMPPConnection.addConnectionCreationListener(new ConnectionCreationListener() { 111 public void connectionCreated(XMPPConnection connection) { 112 getInstanceFor(connection); 113 } 114 }); 115 116 try { 117 MessageDigest sha1MessageDigest = MessageDigest.getInstance("SHA-1"); 118 SUPPORTED_HASHES.put("sha-1", sha1MessageDigest); 119 } catch (NoSuchAlgorithmException e) { 120 // Ignore 121 } 122 } 123 124 /** 125 * Set the default entity node that will be used for new EntityCapsManagers 126 * 127 * @param entityNode 128 */ 129 public static void setDefaultEntityNode(String entityNode) { 130 DEFAULT_ENTITY_NODE = entityNode; 131 } 132 133 /** 134 * Add DiscoverInfo to the database. 135 * 136 * @param nodeVer 137 * The node and verification String (e.g. 138 * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). 139 * @param info 140 * DiscoverInfo for the specified node. 141 */ 142 public static void addDiscoverInfoByNode(String nodeVer, DiscoverInfo info) { 143 caps.put(nodeVer, info); 144 145 if (persistentCache != null) 146 persistentCache.addDiscoverInfoByNodePersistent(nodeVer, info); 147 } 148 149 /** 150 * Get the Node version (node#ver) of a JID. Returns a String or null if 151 * EntiyCapsManager does not have any information. 152 * 153 * @param jid 154 * the user (Full JID) 155 * @return the node version (node#ver) or null 156 */ 157 public static String getNodeVersionByJid(String jid) { 158 NodeVerHash nvh = jidCaps.get(jid); 159 if (nvh != null) { 160 return nvh.nodeVer; 161 } else { 162 return null; 163 } 164 } 165 166 public static NodeVerHash getNodeVerHashByJid(String jid) { 167 return jidCaps.get(jid); 168 } 169 170 /** 171 * Get the discover info given a user name. The discover info is returned if 172 * the user has a node#ver associated with it and the node#ver has a 173 * discover info associated with it. 174 * 175 * @param user 176 * user name (Full JID) 177 * @return the discovered info 178 */ 179 public static DiscoverInfo getDiscoverInfoByUser(String user) { 180 NodeVerHash nvh = jidCaps.get(user); 181 if (nvh == null) 182 return null; 183 184 return getDiscoveryInfoByNodeVer(nvh.nodeVer); 185 } 186 187 /** 188 * Retrieve DiscoverInfo for a specific node. 189 * 190 * @param nodeVer 191 * The node name (e.g. 192 * "http://psi-im.org#q07IKJEyjvHSyhy//CH0CxmKi8w="). 193 * @return The corresponding DiscoverInfo or null if none is known. 194 */ 195 public static DiscoverInfo getDiscoveryInfoByNodeVer(String nodeVer) { 196 DiscoverInfo info = caps.get(nodeVer); 197 if (info != null) 198 info = new DiscoverInfo(info); 199 200 return info; 201 } 202 203 /** 204 * Set the persistent cache implementation 205 * 206 * @param cache 207 * @throws IOException 208 */ 209 public static void setPersistentCache(EntityCapsPersistentCache cache) throws IOException { 210 if (persistentCache != null) 211 throw new IllegalStateException("Entity Caps Persistent Cache was already set"); 212 persistentCache = cache; 213 persistentCache.replay(); 214 } 215 216 /** 217 * Sets the maximum Cache size for the JID to nodeVer Cache 218 * 219 * @param maxCacheSize 220 */ 221 @SuppressWarnings("rawtypes") 222 public static void setJidCapsMaxCacheSize(int maxCacheSize) { 223 ((Cache) jidCaps).setMaxCacheSize(maxCacheSize); 224 } 225 226 /** 227 * Sets the maximum Cache size for the nodeVer to DiscoverInfo Cache 228 * 229 * @param maxCacheSize 230 */ 231 @SuppressWarnings("rawtypes") 232 public static void setCapsMaxCacheSize(int maxCacheSize) { 233 ((Cache) caps).setMaxCacheSize(maxCacheSize); 234 } 235 236 private ServiceDiscoveryManager sdm; 237 private boolean entityCapsEnabled; 238 private String currentCapsVersion; 239 private boolean presenceSend = false; 240 private Queue<String> lastLocalCapsVersions = new ConcurrentLinkedQueue<String>(); 241 242 /** 243 * The entity node String used by this EntityCapsManager instance. 244 */ 245 private String entityNode = DEFAULT_ENTITY_NODE; 246 247 private EntityCapsManager(XMPPConnection connection) { 248 super(connection); 249 this.sdm = ServiceDiscoveryManager.getInstanceFor(connection); 250 instances.put(connection, this); 251 252 connection.addConnectionListener(new AbstractConnectionListener() { 253 @Override 254 public void connectionClosed() { 255 presenceSend = false; 256 } 257 @Override 258 public void connectionClosedOnError(Exception e) { 259 presenceSend = false; 260 } 261 }); 262 263 // This calculates the local entity caps version 264 updateLocalEntityCaps(); 265 266 if (autoEnableEntityCaps) 267 enableEntityCaps(); 268 269 connection.addPacketListener(new PacketListener() { 270 // Listen for remote presence stanzas with the caps extension 271 // If we receive such a stanza, record the JID and nodeVer 272 @Override 273 public void processPacket(Packet packet) { 274 if (!entityCapsEnabled()) 275 return; 276 277 CapsExtension ext = (CapsExtension) packet.getExtension(EntityCapsManager.ELEMENT, 278 EntityCapsManager.NAMESPACE); 279 280 String hash = ext.getHash().toLowerCase(Locale.US); 281 if (!SUPPORTED_HASHES.containsKey(hash)) 282 return; 283 284 String from = packet.getFrom(); 285 String node = ext.getNode(); 286 String ver = ext.getVer(); 287 288 jidCaps.put(from, new NodeVerHash(node, ver, hash)); 289 } 290 291 }, PRESENCES_WITH_CAPS); 292 293 connection.addPacketListener(new PacketListener() { 294 @Override 295 public void processPacket(Packet packet) { 296 // always remove the JID from the map, even if entityCaps are 297 // disabled 298 String from = packet.getFrom(); 299 jidCaps.remove(from); 300 } 301 }, PRESENCES_WITHOUT_CAPS); 302 303 connection.addPacketSendingListener(new PacketListener() { 304 @Override 305 public void processPacket(Packet packet) { 306 presenceSend = true; 307 } 308 }, PRESENCES); 309 310 // Intercept presence packages and add caps data when intended. 311 // XEP-0115 specifies that a client SHOULD include entity capabilities 312 // with every presence notification it sends. 313 PacketInterceptor packetInterceptor = new PacketInterceptor() { 314 public void interceptPacket(Packet packet) { 315 if (!entityCapsEnabled) 316 return; 317 318 CapsExtension caps = new CapsExtension(entityNode, getCapsVersion(), "sha-1"); 319 packet.addExtension(caps); 320 } 321 }; 322 connection.addPacketInterceptor(packetInterceptor, PRESENCES); 323 // It's important to do this as last action. Since it changes the 324 // behavior of the SDM in some ways 325 sdm.setEntityCapsManager(this); 326 } 327 328 public static synchronized EntityCapsManager getInstanceFor(XMPPConnection connection) { 329 if (SUPPORTED_HASHES.size() <= 0) 330 throw new IllegalStateException("No supported hashes for EntityCapsManager"); 331 332 EntityCapsManager entityCapsManager = instances.get(connection); 333 334 if (entityCapsManager == null) { 335 entityCapsManager = new EntityCapsManager(connection); 336 } 337 338 return entityCapsManager; 339 } 340 341 public synchronized void enableEntityCaps() { 342 // Add Entity Capabilities (XEP-0115) feature node. 343 sdm.addFeature(NAMESPACE); 344 updateLocalEntityCaps(); 345 entityCapsEnabled = true; 346 } 347 348 public synchronized void disableEntityCaps() { 349 entityCapsEnabled = false; 350 sdm.removeFeature(NAMESPACE); 351 } 352 353 public boolean entityCapsEnabled() { 354 return entityCapsEnabled; 355 } 356 357 public void setEntityNode(String entityNode) throws NotConnectedException { 358 this.entityNode = entityNode; 359 updateLocalEntityCaps(); 360 } 361 362 /** 363 * Remove a record telling what entity caps node a user has. 364 * 365 * @param user 366 * the user (Full JID) 367 */ 368 public void removeUserCapsNode(String user) { 369 jidCaps.remove(user); 370 } 371 372 /** 373 * Get our own caps version. The version depends on the enabled features. A 374 * caps version looks like '66/0NaeaBKkwk85efJTGmU47vXI=' 375 * 376 * @return our own caps version 377 */ 378 public String getCapsVersion() { 379 return currentCapsVersion; 380 } 381 382 /** 383 * Returns the local entity's NodeVer (e.g. 384 * "http://www.igniterealtime.org/projects/smack/#66/0NaeaBKkwk85efJTGmU47vXI= 385 * ) 386 * 387 * @return the local NodeVer 388 */ 389 public String getLocalNodeVer() { 390 return entityNode + '#' + getCapsVersion(); 391 } 392 393 /** 394 * Returns true if Entity Caps are supported by a given JID 395 * 396 * @param jid 397 * @return true if the entity supports Entity Capabilities. 398 * @throws XMPPErrorException 399 * @throws NoResponseException 400 * @throws NotConnectedException 401 */ 402 public boolean areEntityCapsSupported(String jid) throws NoResponseException, XMPPErrorException, NotConnectedException { 403 return sdm.supportsFeature(jid, NAMESPACE); 404 } 405 406 /** 407 * Returns true if Entity Caps are supported by the local service/server 408 * 409 * @return true if the user's server supports Entity Capabilities. 410 * @throws XMPPErrorException 411 * @throws NoResponseException 412 * @throws NotConnectedException 413 */ 414 public boolean areEntityCapsSupportedByServer() throws NoResponseException, XMPPErrorException, NotConnectedException { 415 return areEntityCapsSupported(connection().getServiceName()); 416 } 417 418 /** 419 * Updates the local user Entity Caps information with the data provided 420 * 421 * If we are connected and there was already a presence send, another 422 * presence is send to inform others about your new Entity Caps node string. 423 * 424 */ 425 public void updateLocalEntityCaps() { 426 XMPPConnection connection = connection(); 427 428 DiscoverInfo discoverInfo = new DiscoverInfo(); 429 discoverInfo.setType(IQ.Type.RESULT); 430 discoverInfo.setNode(getLocalNodeVer()); 431 if (connection != null) 432 discoverInfo.setFrom(connection.getUser()); 433 sdm.addDiscoverInfoTo(discoverInfo); 434 435 currentCapsVersion = generateVerificationString(discoverInfo, "sha-1"); 436 addDiscoverInfoByNode(entityNode + '#' + currentCapsVersion, discoverInfo); 437 if (lastLocalCapsVersions.size() > 10) { 438 String oldCapsVersion = lastLocalCapsVersions.poll(); 439 sdm.removeNodeInformationProvider(entityNode + '#' + oldCapsVersion); 440 } 441 lastLocalCapsVersions.add(currentCapsVersion); 442 443 caps.put(currentCapsVersion, discoverInfo); 444 if (connection != null) 445 jidCaps.put(connection.getUser(), new NodeVerHash(entityNode, currentCapsVersion, "sha-1")); 446 447 final List<Identity> identities = new LinkedList<Identity>(ServiceDiscoveryManager.getInstanceFor(connection).getIdentities()); 448 sdm.setNodeInformationProvider(entityNode + '#' + currentCapsVersion, new NodeInformationProvider() { 449 List<String> features = sdm.getFeaturesList(); 450 List<PacketExtension> packetExtensions = sdm.getExtendedInfoAsList(); 451 452 @Override 453 public List<Item> getNodeItems() { 454 return null; 455 } 456 457 @Override 458 public List<String> getNodeFeatures() { 459 return features; 460 } 461 462 @Override 463 public List<Identity> getNodeIdentities() { 464 return identities; 465 } 466 467 @Override 468 public List<PacketExtension> getNodePacketExtensions() { 469 return packetExtensions; 470 } 471 }); 472 473 // Send an empty presence, and let the packet intercepter 474 // add a <c/> node to it. 475 // See http://xmpp.org/extensions/xep-0115.html#advertise 476 // We only send a presence packet if there was already one send 477 // to respect ConnectionConfiguration.isSendPresence() 478 if (connection != null && connection.isAuthenticated() && presenceSend) { 479 Presence presence = new Presence(Presence.Type.available); 480 try { 481 connection.sendPacket(presence); 482 } 483 catch (NotConnectedException e) { 484 LOGGER.log(Level.WARNING, "Could could not update presence with caps info", e); 485 } 486 } 487 } 488 489 /** 490 * Verify DisoverInfo and Caps Node as defined in XEP-0115 5.4 Processing 491 * Method 492 * 493 * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver-proc">XEP-0115 494 * 5.4 Processing Method</a> 495 * 496 * @param ver 497 * @param hash 498 * @param info 499 * @return true if it's valid and should be cache, false if not 500 */ 501 public static boolean verifyDiscoverInfoVersion(String ver, String hash, DiscoverInfo info) { 502 // step 3.3 check for duplicate identities 503 if (info.containsDuplicateIdentities()) 504 return false; 505 506 // step 3.4 check for duplicate features 507 if (info.containsDuplicateFeatures()) 508 return false; 509 510 // step 3.5 check for well-formed packet extensions 511 if (verifyPacketExtensions(info)) 512 return false; 513 514 String calculatedVer = generateVerificationString(info, hash); 515 516 if (!ver.equals(calculatedVer)) 517 return false; 518 519 return true; 520 } 521 522 /** 523 * 524 * @param info 525 * @return true if the packet extensions is ill-formed 526 */ 527 protected static boolean verifyPacketExtensions(DiscoverInfo info) { 528 List<FormField> foundFormTypes = new LinkedList<FormField>(); 529 for (PacketExtension pe : info.getExtensions()) { 530 if (pe.getNamespace().equals(DataForm.NAMESPACE)) { 531 DataForm df = (DataForm) pe; 532 for (FormField f : df.getFields()) { 533 if (f.getVariable().equals("FORM_TYPE")) { 534 for (FormField fft : foundFormTypes) { 535 if (f.equals(fft)) 536 return true; 537 } 538 foundFormTypes.add(f); 539 } 540 } 541 } 542 } 543 return false; 544 } 545 546 /** 547 * Generates a XEP-115 Verification String 548 * 549 * @see <a href="http://xmpp.org/extensions/xep-0115.html#ver">XEP-115 550 * Verification String</a> 551 * 552 * @param discoverInfo 553 * @param hash 554 * the used hash function 555 * @return The generated verification String or null if the hash is not 556 * supported 557 */ 558 protected static String generateVerificationString(DiscoverInfo discoverInfo, String hash) { 559 MessageDigest md = SUPPORTED_HASHES.get(hash.toLowerCase(Locale.US)); 560 if (md == null) 561 return null; 562 563 DataForm extendedInfo = (DataForm) discoverInfo.getExtension(DataForm.ELEMENT, DataForm.NAMESPACE); 564 565 // 1. Initialize an empty string S ('sb' in this method). 566 StringBuilder sb = new StringBuilder(); // Use StringBuilder as we don't 567 // need thread-safe StringBuffer 568 569 // 2. Sort the service discovery identities by category and then by 570 // type and then by xml:lang 571 // (if it exists), formatted as CATEGORY '/' [TYPE] '/' [LANG] '/' 572 // [NAME]. Note that each slash is included even if the LANG or 573 // NAME is not included (in accordance with XEP-0030, the category and 574 // type MUST be included. 575 SortedSet<DiscoverInfo.Identity> sortedIdentities = new TreeSet<DiscoverInfo.Identity>(); 576 577 for (DiscoverInfo.Identity i : discoverInfo.getIdentities()) 578 sortedIdentities.add(i); 579 580 // 3. For each identity, append the 'category/type/lang/name' to S, 581 // followed by the '<' character. 582 for (DiscoverInfo.Identity identity : sortedIdentities) { 583 sb.append(identity.getCategory()); 584 sb.append("/"); 585 sb.append(identity.getType()); 586 sb.append("/"); 587 sb.append(identity.getLanguage() == null ? "" : identity.getLanguage()); 588 sb.append("/"); 589 sb.append(identity.getName() == null ? "" : identity.getName()); 590 sb.append("<"); 591 } 592 593 // 4. Sort the supported service discovery features. 594 SortedSet<String> features = new TreeSet<String>(); 595 for (Feature f : discoverInfo.getFeatures()) 596 features.add(f.getVar()); 597 598 // 5. For each feature, append the feature to S, followed by the '<' 599 // character 600 for (String f : features) { 601 sb.append(f); 602 sb.append("<"); 603 } 604 605 // only use the data form for calculation is it has a hidden FORM_TYPE 606 // field 607 // see XEP-0115 5.4 step 3.6 608 if (extendedInfo != null && extendedInfo.hasHiddenFormTypeField()) { 609 synchronized (extendedInfo) { 610 // 6. If the service discovery information response includes 611 // XEP-0128 data forms, sort the forms by the FORM_TYPE (i.e., 612 // by the XML character data of the <value/> element). 613 SortedSet<FormField> fs = new TreeSet<FormField>(new Comparator<FormField>() { 614 public int compare(FormField f1, FormField f2) { 615 return f1.getVariable().compareTo(f2.getVariable()); 616 } 617 }); 618 619 FormField ft = null; 620 621 for (FormField f : extendedInfo.getFields()) { 622 if (!f.getVariable().equals("FORM_TYPE")) { 623 fs.add(f); 624 } else { 625 ft = f; 626 } 627 } 628 629 // Add FORM_TYPE values 630 if (ft != null) { 631 formFieldValuesToCaps(ft.getValues(), sb); 632 } 633 634 // 7. 3. For each field other than FORM_TYPE: 635 // 1. Append the value of the "var" attribute, followed by the 636 // '<' character. 637 // 2. Sort values by the XML character data of the <value/> 638 // element. 639 // 3. For each <value/> element, append the XML character data, 640 // followed by the '<' character. 641 for (FormField f : fs) { 642 sb.append(f.getVariable()); 643 sb.append("<"); 644 formFieldValuesToCaps(f.getValues(), sb); 645 } 646 } 647 } 648 // 8. Ensure that S is encoded according to the UTF-8 encoding (RFC 649 // 3269). 650 // 9. Compute the verification string by hashing S using the algorithm 651 // specified in the 'hash' attribute (e.g., SHA-1 as defined in RFC 652 // 3174). 653 // The hashed data MUST be generated with binary output and 654 // encoded using Base64 as specified in Section 4 of RFC 4648 655 // (note: the Base64 output MUST NOT include whitespace and MUST set 656 // padding bits to zero). 657 byte[] digest = md.digest(sb.toString().getBytes()); 658 return Base64.encodeBytes(digest); 659 } 660 661 private static void formFieldValuesToCaps(List<String> i, StringBuilder sb) { 662 SortedSet<String> fvs = new TreeSet<String>(); 663 for (String s : i) { 664 fvs.add(s); 665 } 666 for (String fv : fvs) { 667 sb.append(fv); 668 sb.append("<"); 669 } 670 } 671 672 public static class NodeVerHash { 673 private String node; 674 private String hash; 675 private String ver; 676 private String nodeVer; 677 678 NodeVerHash(String node, String ver, String hash) { 679 this.node = node; 680 this.ver = ver; 681 this.hash = hash; 682 nodeVer = node + "#" + ver; 683 } 684 685 public String getNodeVer() { 686 return nodeVer; 687 } 688 689 public String getNode() { 690 return node; 691 } 692 693 public String getHash() { 694 return hash; 695 } 696 697 public String getVer() { 698 return ver; 699 } 700 } 701}