001/**
002 *
003 * Copyright 2013-2014 Georg Lukas, 2017 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.carbons;
018
019import java.util.Map;
020import java.util.Set;
021import java.util.WeakHashMap;
022import java.util.concurrent.CopyOnWriteArraySet;
023
024import org.jivesoftware.smack.AbstractConnectionListener;
025import org.jivesoftware.smack.ConnectionCreationListener;
026import org.jivesoftware.smack.ExceptionCallback;
027import org.jivesoftware.smack.Manager;
028import org.jivesoftware.smack.SmackException;
029import org.jivesoftware.smack.SmackException.NoResponseException;
030import org.jivesoftware.smack.SmackException.NotConnectedException;
031import org.jivesoftware.smack.StanzaListener;
032import org.jivesoftware.smack.XMPPConnection;
033import org.jivesoftware.smack.XMPPConnectionRegistry;
034import org.jivesoftware.smack.XMPPException;
035import org.jivesoftware.smack.XMPPException.XMPPErrorException;
036import org.jivesoftware.smack.filter.AndFilter;
037import org.jivesoftware.smack.filter.FromMatchesFilter;
038import org.jivesoftware.smack.filter.OrFilter;
039import org.jivesoftware.smack.filter.StanzaExtensionFilter;
040import org.jivesoftware.smack.filter.StanzaFilter;
041import org.jivesoftware.smack.filter.StanzaTypeFilter;
042import org.jivesoftware.smack.packet.IQ;
043import org.jivesoftware.smack.packet.Message;
044import org.jivesoftware.smack.packet.Stanza;
045import org.jivesoftware.smackx.carbons.packet.Carbon;
046import org.jivesoftware.smackx.carbons.packet.CarbonExtension;
047import org.jivesoftware.smackx.carbons.packet.CarbonExtension.Direction;
048import org.jivesoftware.smackx.carbons.packet.CarbonExtension.Private;
049import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
050import org.jivesoftware.smackx.forward.packet.Forwarded;
051import org.jxmpp.jid.EntityFullJid;
052
053/**
054 * Manager for XEP-0280: Message Carbons. This class implements the manager for registering {@link CarbonExtension}
055 * support, enabling and disabling message carbons, and for {@link CarbonCopyReceivedListener}.
056 * <p>
057 * Note that <b>it is important to match the 'from' attribute of the message wrapping a carbon copy</b>, as otherwise it would
058 * may be possible for others to impersonate users. Smack's CarbonManager takes care of that in
059 * {@link CarbonCopyReceivedListener}s which where registered with
060 * {@link #addCarbonCopyReceivedListener(CarbonCopyReceivedListener)}.
061 * </p>
062 * <p>
063 * You should call enableCarbons() before sending your first undirected presence (aka. the "initial presence").
064 * </p>
065 *
066 * @author Georg Lukas
067 * @author Florian Schmaus
068 */
069public final class CarbonManager extends Manager {
070
071    private static Map<XMPPConnection, CarbonManager> INSTANCES = new WeakHashMap<XMPPConnection, CarbonManager>();
072
073    static {
074        XMPPConnectionRegistry.addConnectionCreationListener(new ConnectionCreationListener() {
075            @Override
076            public void connectionCreated(XMPPConnection connection) {
077                getInstanceFor(connection);
078            }
079        });
080    }
081
082    private static final StanzaFilter CARBON_EXTENSION_FILTER = 
083                    // @formatter:off
084                    new AndFilter(
085                        new OrFilter(
086                            new StanzaExtensionFilter(CarbonExtension.Direction.sent.name(), CarbonExtension.NAMESPACE),
087                            new StanzaExtensionFilter(CarbonExtension.Direction.received.name(), CarbonExtension.NAMESPACE)
088                        ),
089                        StanzaTypeFilter.MESSAGE
090                    );
091                    // @formatter:on
092
093    private final Set<CarbonCopyReceivedListener> listeners = new CopyOnWriteArraySet<>();
094
095    private volatile boolean enabled_state = false;
096
097    private final StanzaListener carbonsListener;
098
099    private CarbonManager(XMPPConnection connection) {
100        super(connection);
101        ServiceDiscoveryManager sdm = ServiceDiscoveryManager.getInstanceFor(connection);
102        sdm.addFeature(CarbonExtension.NAMESPACE);
103
104        carbonsListener = new StanzaListener() {
105            @Override
106            public void processStanza(final Stanza stanza) throws NotConnectedException, InterruptedException {
107                final Message wrappingMessage = (Message) stanza;
108                final CarbonExtension carbonExtension = CarbonExtension.from(wrappingMessage);
109                final Direction direction = carbonExtension.getDirection();
110                final Forwarded forwarded = carbonExtension.getForwarded();
111                final Message carbonCopy = (Message) forwarded.getForwardedStanza();
112                for (CarbonCopyReceivedListener listener : listeners) {
113                    listener.onCarbonCopyReceived(direction, carbonCopy, wrappingMessage);
114                }
115            }
116        };
117
118        connection.addConnectionListener(new AbstractConnectionListener() {
119            @Override
120            public void connectionClosed() {
121                // Reset the state if the connection was cleanly closed. Note that this is not strictly necessary,
122                // because we also reset in authenticated() if the stream got not resumed, but for maximum correctness,
123                // also reset here.
124                enabled_state = false;
125                boolean removed = connection().removeSyncStanzaListener(carbonsListener);
126                assert(removed);
127            }
128            @Override
129            public void authenticated(XMPPConnection connection, boolean resumed) {
130                if (!resumed) {
131                    // Non-resumed XMPP sessions always start with disabled carbons
132                    enabled_state = false;
133                }
134                addCarbonsListener(connection);
135            }
136        });
137
138        addCarbonsListener(connection);
139    }
140
141    private void addCarbonsListener(XMPPConnection connection) {
142        EntityFullJid localAddress = connection.getUser();
143        if (localAddress == null) {
144            // We where not connected yet and thus we don't know our XMPP address at the moment, which we need to match incoming
145            // carbons securely. Abort here. The ConnectionListener above will eventually setup the carbons listener.
146            return;
147        }
148
149        // XEP-0280 ยง 11. Security Considerations "Any forwarded copies received by a Carbons-enabled client MUST be
150        // from that user's bare JID; any copies that do not meet this requirement MUST be ignored." Otherwise, if
151        // those copies do not get ignored, malicious users may be able to impersonate other users. That is why the
152        // 'from' matcher is important here.
153        connection.addSyncStanzaListener(carbonsListener, new AndFilter(CARBON_EXTENSION_FILTER,
154                        FromMatchesFilter.createBare(localAddress)));
155    }
156
157    /**
158     * Obtain the CarbonManager responsible for a connection.
159     *
160     * @param connection the connection object.
161     *
162     * @return a CarbonManager instance
163     */
164    public static synchronized CarbonManager getInstanceFor(XMPPConnection connection) {
165        CarbonManager carbonManager = INSTANCES.get(connection);
166
167        if (carbonManager == null) {
168            carbonManager = new CarbonManager(connection);
169            INSTANCES.put(connection, carbonManager);
170        }
171
172        return carbonManager;
173    }
174
175    private static IQ carbonsEnabledIQ(final boolean new_state) {
176        IQ request;
177        if (new_state) {
178            request = new Carbon.Enable();
179        } else {
180            request = new Carbon.Disable();
181        }
182        return request;
183    }
184
185    /**
186     * Add a carbon copy received listener.
187     *
188     * @param listener the listener to register.
189     * @return <code>true</code> if the filter was not already registered.
190     * @since 4.2
191     */
192    public boolean addCarbonCopyReceivedListener(CarbonCopyReceivedListener listener) {
193        return listeners.add(listener);
194    }
195
196    /**
197     * Remove a carbon copy received listener.
198     *
199     * @param listener the listener to register.
200     * @return <code>true</code> if the filter was registered.
201     * @since 4.2
202     */
203    public boolean removeCarbonCopyReceivedListener(CarbonCopyReceivedListener listener) {
204        return listeners.remove(listener);
205    }
206
207    /**
208     * Returns true if XMPP Carbons are supported by the server.
209     * 
210     * @return true if supported
211     * @throws NotConnectedException 
212     * @throws XMPPErrorException 
213     * @throws NoResponseException 
214     * @throws InterruptedException 
215     */
216    public boolean isSupportedByServer() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
217        return ServiceDiscoveryManager.getInstanceFor(connection()).serverSupportsFeature(CarbonExtension.NAMESPACE);
218    }
219
220    /**
221     * Notify server to change the carbons state. This method returns
222     * immediately and changes the variable when the reply arrives.
223     *
224     * You should first check for support using isSupportedByServer().
225     *
226     * @param new_state whether carbons should be enabled or disabled
227     * @throws NotConnectedException 
228     * @throws InterruptedException
229     * @deprecated use {@link #enableCarbonsAsync(ExceptionCallback)} or {@link #disableCarbonsAsync(ExceptionCallback)} instead.
230     */
231    @Deprecated
232    public void sendCarbonsEnabled(final boolean new_state) throws NotConnectedException, InterruptedException {
233        sendUseCarbons(new_state, null);
234    }
235
236    /**
237     * Enable carbons asynchronously. If an error occurs as result of the attempt to enable carbons, the optional
238     * <code>exceptionCallback</code> will be invoked.
239     * <p>
240     * Note that although this method is asynchronous, it may block if the outgoing stream element queue is full (e.g.
241     * because of a slow network connection). Thus, if the thread performing this operation is interrupted while the
242     * queue is full, an {@link InterruptedException} is thrown.
243     * </p>
244     * 
245     * @param exceptionCallback the optional exception callback.
246     * @throws InterruptedException if the thread got interrupted while this action is performed.
247     * @since 4.2
248     */
249    public void enableCarbonsAsync(ExceptionCallback exceptionCallback) throws InterruptedException {
250        sendUseCarbons(true, exceptionCallback);
251    }
252
253    /**
254     * Disable carbons asynchronously. If an error occurs as result of the attempt to disable carbons, the optional
255     * <code>exceptionCallback</code> will be invoked.
256     * <p>
257     * Note that although this method is asynchronous, it may block if the outgoing stream element queue is full (e.g.
258     * because of a slow network connection). Thus, if the thread performing this operation is interrupted while the
259     * queue is full, an {@link InterruptedException} is thrown.
260     * </p>
261     * 
262     * @param exceptionCallback the optional exception callback.
263     * @throws InterruptedException if the thread got interrupted while this action is performed.
264     * @since 4.2
265     */
266    public void disableCarbonsAsync(ExceptionCallback exceptionCallback) throws InterruptedException {
267        sendUseCarbons(false, exceptionCallback);
268    }
269
270    private void sendUseCarbons(final boolean use, ExceptionCallback exceptionCallback) throws InterruptedException {
271        IQ setIQ = carbonsEnabledIQ(use);
272
273        try {
274            connection().sendIqWithResponseCallback(setIQ, new StanzaListener() {
275                @Override
276                public void processStanza(Stanza packet) {
277                    enabled_state = use;
278                }
279            }, exceptionCallback);
280        }
281        catch (NotConnectedException e) {
282            if (exceptionCallback != null) {
283                exceptionCallback.processException(e);
284            }
285        }
286    }
287
288    /**
289     * Notify server to change the carbons state. This method blocks
290     * some time until the server replies to the IQ and returns true on
291     * success.
292     *
293     * You should first check for support using isSupportedByServer().
294     *
295     * @param new_state whether carbons should be enabled or disabled
296     * @throws XMPPErrorException 
297     * @throws NoResponseException 
298     * @throws NotConnectedException 
299     * @throws InterruptedException 
300     *
301     */
302    public synchronized void setCarbonsEnabled(final boolean new_state) throws NoResponseException,
303                    XMPPErrorException, NotConnectedException, InterruptedException {
304        if (enabled_state == new_state)
305            return;
306
307        IQ setIQ = carbonsEnabledIQ(new_state);
308
309        connection().createStanzaCollectorAndSend(setIQ).nextResultOrThrow();
310        enabled_state = new_state;
311    }
312
313    /**
314     * Helper method to enable carbons.
315     *
316     * @throws XMPPException 
317     * @throws SmackException if there was no response from the server.
318     * @throws InterruptedException 
319     */
320    public void enableCarbons() throws XMPPException, SmackException, InterruptedException {
321        setCarbonsEnabled(true);
322    }
323
324    /**
325     * Helper method to disable carbons.
326     *
327     * @throws XMPPException 
328     * @throws SmackException if there was no response from the server.
329     * @throws InterruptedException 
330     */
331    public void disableCarbons() throws XMPPException, SmackException, InterruptedException {
332        setCarbonsEnabled(false);
333    }
334
335    /**
336     * Check if carbons are enabled on this connection.
337     */
338    public boolean getCarbonsEnabled() {
339        return this.enabled_state;
340    }
341
342    /**
343     * Mark a message as "private", so it will not be carbon-copied.
344     *
345     * @param msg Message object to mark private
346     * @deprecated use {@link Private#addTo(Message)}
347     */
348    @Deprecated
349    public static void disableCarbons(Message msg) {
350        msg.addExtension(Private.INSTANCE);
351    }
352}