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