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