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                boolean removed = connection().removeSyncStanzaListener(carbonsListener);
158                assert removed;
159            }
160            @Override
161            public void authenticated(XMPPConnection connection, boolean resumed) {
162                if (!resumed) {
163                    // Non-resumed XMPP sessions always start with disabled carbons
164                    enabled_state = false;
165                    try {
166                        if (shouldCarbonsBeEnabled() && isSupportedByServer()) {
167                            setCarbonsEnabled(true);
168                        }
169                    } catch (InterruptedException | XMPPErrorException | NotConnectedException | NoResponseException e) {
170                        LOGGER.log(Level.WARNING, "Cannot check for Carbon support and / or enable carbons.", e);
171                    }
172                }
173                addCarbonsListener(connection);
174            }
175        });
176
177        addCarbonsListener(connection);
178    }
179
180    private void addCarbonsListener(XMPPConnection connection) {
181        EntityFullJid localAddress = connection.getUser();
182        if (localAddress == null) {
183            // We where not connected yet and thus we don't know our XMPP address at the moment, which we need to match incoming
184            // carbons securely. Abort here. The ConnectionListener above will eventually setup the carbons listener.
185            return;
186        }
187
188        // XEP-0280 ยง 11. Security Considerations "Any forwarded copies received by a Carbons-enabled client MUST be
189        // from that user's bare JID; any copies that do not meet this requirement MUST be ignored." Otherwise, if
190        // those copies do not get ignored, malicious users may be able to impersonate other users. That is why the
191        // 'from' matcher is important here.
192        connection.addSyncStanzaListener(carbonsListener, new AndFilter(CARBON_EXTENSION_FILTER,
193                        FromMatchesFilter.createBare(localAddress)));
194    }
195
196    /**
197     * Obtain the CarbonManager responsible for a connection.
198     *
199     * @param connection the connection object.
200     *
201     * @return a CarbonManager instance
202     */
203    public static synchronized CarbonManager getInstanceFor(XMPPConnection connection) {
204        CarbonManager carbonManager = INSTANCES.get(connection);
205
206        if (carbonManager == null) {
207            carbonManager = new CarbonManager(connection);
208            INSTANCES.put(connection, carbonManager);
209        }
210
211        return carbonManager;
212    }
213
214    private static IQ carbonsEnabledIQ(final boolean new_state) {
215        IQ request;
216        if (new_state) {
217            request = new Carbon.Enable();
218        } else {
219            request = new Carbon.Disable();
220        }
221        return request;
222    }
223
224    /**
225     * Add a carbon copy received listener.
226     *
227     * @param listener the listener to register.
228     * @return <code>true</code> if the filter was not already registered.
229     * @since 4.2
230     */
231    public boolean addCarbonCopyReceivedListener(CarbonCopyReceivedListener listener) {
232        return listeners.add(listener);
233    }
234
235    /**
236     * Remove a carbon copy received listener.
237     *
238     * @param listener the listener to register.
239     * @return <code>true</code> if the filter was registered.
240     * @since 4.2
241     */
242    public boolean removeCarbonCopyReceivedListener(CarbonCopyReceivedListener listener) {
243        return listeners.remove(listener);
244    }
245
246    /**
247     * Returns true if XMPP Carbons are supported by the server.
248     *
249     * @return true if supported
250     * @throws NotConnectedException if the XMPP connection is not connected.
251     * @throws XMPPErrorException if there was an XMPP error returned.
252     * @throws NoResponseException if there was no response from the remote entity.
253     * @throws InterruptedException if the calling thread was interrupted.
254     */
255    public boolean isSupportedByServer() throws NoResponseException, XMPPErrorException, NotConnectedException, InterruptedException {
256        return ServiceDiscoveryManager.getInstanceFor(connection()).serverSupportsFeature(CarbonExtension.NAMESPACE);
257    }
258
259    /**
260     * Notify server to change the carbons state. This method returns
261     * immediately and changes the variable when the reply arrives.
262     *
263     * You should first check for support using isSupportedByServer().
264     *
265     * @param new_state whether carbons should be enabled or disabled
266     * @deprecated use {@link #enableCarbonsAsync(ExceptionCallback)} or {@link #disableCarbonsAsync(ExceptionCallback)} instead.
267     */
268    @Deprecated
269    public void sendCarbonsEnabled(final boolean new_state) {
270        sendUseCarbons(new_state, null);
271    }
272
273    /**
274     * Enable carbons asynchronously. If an error occurs as result of the attempt to enable carbons, the optional
275     * <code>exceptionCallback</code> will be invoked.
276     * <p>
277     * Note that although this method is asynchronous, it may block if the outgoing stream element queue is full (e.g.
278     * because of a slow network connection). Thus, if the thread performing this operation is interrupted while the
279     * queue is full, an {@link InterruptedException} is thrown.
280     * </p>
281     *
282     * @param exceptionCallback the optional exception callback.
283     * @since 4.2
284     */
285    public void enableCarbonsAsync(ExceptionCallback<Exception> exceptionCallback) {
286        sendUseCarbons(true, exceptionCallback);
287    }
288
289    /**
290     * Disable carbons asynchronously. If an error occurs as result of the attempt to disable carbons, the optional
291     * <code>exceptionCallback</code> will be invoked.
292     * <p>
293     * Note that although this method is asynchronous, it may block if the outgoing stream element queue is full (e.g.
294     * because of a slow network connection). Thus, if the thread performing this operation is interrupted while the
295     * queue is full, an {@link InterruptedException} is thrown.
296     * </p>
297     *
298     * @param exceptionCallback the optional exception callback.
299     * @since 4.2
300     */
301    public void disableCarbonsAsync(ExceptionCallback<Exception> exceptionCallback) {
302        sendUseCarbons(false, exceptionCallback);
303    }
304
305    private void sendUseCarbons(final boolean use, ExceptionCallback<Exception> exceptionCallback) {
306        enabledByDefault = use;
307        IQ setIQ = carbonsEnabledIQ(use);
308
309        SmackFuture<IQ, Exception> future = connection().sendIqRequestAsync(setIQ);
310
311        future.onSuccess(new SuccessCallback<IQ>() {
312
313            @Override
314            public void onSuccess(IQ result) {
315                enabled_state = use;
316            }
317        }).onError(exceptionCallback);
318    }
319
320    /**
321     * Notify server to change the carbons state. This method blocks
322     * some time until the server replies to the IQ and returns true on
323     * success.
324     *
325     * You should first check for support using isSupportedByServer().
326     *
327     * @param new_state whether carbons should be enabled or disabled
328     * @throws XMPPErrorException if there was an XMPP error returned.
329     * @throws NoResponseException if there was no response from the remote entity.
330     * @throws NotConnectedException if the XMPP connection is not connected.
331     * @throws InterruptedException if the calling thread was interrupted.
332     *
333     */
334    public synchronized void setCarbonsEnabled(final boolean new_state) throws NoResponseException,
335                    XMPPErrorException, NotConnectedException, InterruptedException {
336        enabledByDefault = new_state;
337        if (enabled_state == new_state)
338            return;
339
340        IQ setIQ = carbonsEnabledIQ(new_state);
341
342        connection().createStanzaCollectorAndSend(setIQ).nextResultOrThrow();
343        enabled_state = new_state;
344    }
345
346    /**
347     * Helper method to enable carbons.
348     *
349     * @throws XMPPException if an XMPP protocol error was received.
350     * @throws SmackException if there was no response from the server.
351     * @throws InterruptedException if the calling thread was interrupted.
352     */
353    public void enableCarbons() throws XMPPException, SmackException, InterruptedException {
354        setCarbonsEnabled(true);
355    }
356
357    /**
358     * Helper method to disable carbons.
359     *
360     * @throws XMPPException if an XMPP protocol error was received.
361     * @throws SmackException if there was no response from the server.
362     * @throws InterruptedException if the calling thread was interrupted.
363     */
364    public void disableCarbons() throws XMPPException, SmackException, InterruptedException {
365        setCarbonsEnabled(false);
366    }
367
368    /**
369     * Check if carbons are enabled on this connection.
370     *
371     * @return true if carbons are enabled, else false.
372     */
373    public boolean getCarbonsEnabled() {
374        return this.enabled_state;
375    }
376
377    private boolean shouldCarbonsBeEnabled() {
378        return enabledByDefault;
379    }
380
381    /**
382     * Mark a message as "private", so it will not be carbon-copied.
383     *
384     * @param msg Message object to mark private
385     * @deprecated use {@link Private#addTo(Message)}
386     */
387    @Deprecated
388    public static void disableCarbons(Message msg) {
389        msg.addExtension(Private.INSTANCE);
390    }
391}