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 -&gt; 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}