mirror of
https://codeberg.org/Mercury-IM/Smack
synced 2025-09-09 18:29:45 +02:00
Smack 4.2.1
-----BEGIN PGP SIGNATURE----- iQGTBAABCgB9FiEEl3UFnzoh3OFr5PuuIjmn6PWFIFIFAlmR75tfFIAAAAAALgAo aXNzdWVyLWZwckBub3RhdGlvbnMub3BlbnBncC5maWZ0aGhvcnNlbWFuLm5ldDk3 NzUwNTlGM0EyMURDRTE2QkU0RkJBRTIyMzlBN0U4RjU4NTIwNTIACgkQIjmn6PWF IFLeXggAjdgj7YVUe22NtamnROBj1c3PaWwgSY0gEjcyDPsOz5qeqNUdQLHbmt2j XQQpYZWKg1/1uoQHlsixaFKbGVctKRk72aNEodRfd1osta11WTOwZKEb8nI411Tt 7M0Fhf430WZY6nioZiZIorsmid57fftJ2EMPlmjEDp2FD0AVGAXkEhCneGaPtt9Q hbWbepIy9tApeIH+QgmFLBmPLnFCaSg+X6NUden3Z21bUz5vH8pmcbeUVfsNB7kW nkkDuNwKHPFLgjuhcq7D+KAKRwNU7n8WEuHseRzM7bMCEB+S/rZok5KPXe/tV4v+ YZKN2e+2yh4j5l4FT/fCzELfWcvrgA== =MV3G -----END PGP SIGNATURE----- Merge tag '4.2.1' Smack 4.2.1
This commit is contained in:
commit
43abd52d76
26 changed files with 332 additions and 228 deletions
|
@ -19,7 +19,6 @@ package org.jivesoftware.smackx.omemo;
|
|||
import static org.jivesoftware.smackx.omemo.util.OmemoConstants.BODY_OMEMO_HINT;
|
||||
import static org.jivesoftware.smackx.omemo.util.OmemoConstants.OMEMO;
|
||||
import static org.jivesoftware.smackx.omemo.util.OmemoConstants.OMEMO_NAMESPACE_V_AXOLOTL;
|
||||
import static org.jivesoftware.smackx.omemo.util.OmemoConstants.PEP_NODE_DEVICE_LIST_NOTIFY;
|
||||
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.util.ArrayList;
|
||||
|
@ -32,8 +31,8 @@ import java.util.WeakHashMap;
|
|||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import org.jivesoftware.smack.AbstractConnectionListener;
|
||||
import org.jivesoftware.smack.AbstractXMPPConnection;
|
||||
import org.jivesoftware.smack.ConnectionListener;
|
||||
import org.jivesoftware.smack.Manager;
|
||||
import org.jivesoftware.smack.SmackException;
|
||||
import org.jivesoftware.smack.XMPPConnection;
|
||||
|
@ -41,6 +40,7 @@ import org.jivesoftware.smack.XMPPException;
|
|||
import org.jivesoftware.smack.packet.ExtensionElement;
|
||||
import org.jivesoftware.smack.packet.Message;
|
||||
import org.jivesoftware.smack.packet.Stanza;
|
||||
import org.jivesoftware.smack.util.Async;
|
||||
|
||||
import org.jivesoftware.smackx.carbons.CarbonManager;
|
||||
import org.jivesoftware.smackx.disco.ServiceDiscoveryManager;
|
||||
|
@ -78,7 +78,6 @@ import org.jxmpp.jid.BareJid;
|
|||
import org.jxmpp.jid.DomainBareJid;
|
||||
import org.jxmpp.jid.EntityBareJid;
|
||||
import org.jxmpp.jid.EntityFullJid;
|
||||
import org.jxmpp.jid.FullJid;
|
||||
import org.jxmpp.jid.impl.JidCreate;
|
||||
import org.jxmpp.stringprep.XmppStringprepException;
|
||||
|
||||
|
@ -110,8 +109,29 @@ public final class OmemoManager extends Manager {
|
|||
*/
|
||||
private OmemoManager(XMPPConnection connection, int deviceId) {
|
||||
super(connection);
|
||||
setConnectionListener();
|
||||
|
||||
this.deviceId = deviceId;
|
||||
|
||||
connection.addConnectionListener(new AbstractConnectionListener() {
|
||||
@Override
|
||||
public void authenticated(XMPPConnection connection, boolean resumed) {
|
||||
if (resumed) {
|
||||
return;
|
||||
}
|
||||
Async.go(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
initialize();
|
||||
} catch (InterruptedException | CorruptedOmemoKeyException | PubSubException.NotALeafNodeException | SmackException.NotLoggedInException | SmackException.NoResponseException | SmackException.NotConnectedException | XMPPException.XMPPErrorException e) {
|
||||
LOGGER.log(Level.SEVERE, "connectionListener.authenticated() failed to initialize OmemoManager: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
service = OmemoService.getInstance();
|
||||
}
|
||||
|
||||
|
@ -163,13 +183,13 @@ public final class OmemoManager extends Manager {
|
|||
}
|
||||
}
|
||||
|
||||
int defaulDeviceId = OmemoService.getInstance().getOmemoStoreBackend().getDefaultDeviceId(user);
|
||||
if (defaulDeviceId < 1) {
|
||||
defaulDeviceId = randomDeviceId();
|
||||
OmemoService.getInstance().getOmemoStoreBackend().setDefaultDeviceId(user, defaulDeviceId);
|
||||
int defaultDeviceId = OmemoService.getInstance().getOmemoStoreBackend().getDefaultDeviceId(user);
|
||||
if (defaultDeviceId < 1) {
|
||||
defaultDeviceId = randomDeviceId();
|
||||
OmemoService.getInstance().getOmemoStoreBackend().setDefaultDeviceId(user, defaultDeviceId);
|
||||
}
|
||||
|
||||
return getInstanceFor(connection, defaulDeviceId);
|
||||
return getInstanceFor(connection, defaultDeviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -471,24 +491,6 @@ public final class OmemoManager extends Manager {
|
|||
.getActiveDevices().isEmpty();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true, if the device resource has announced OMEMO support.
|
||||
* Throws an IllegalArgumentException if the provided FullJid does not have a resource part.
|
||||
*
|
||||
* @param fullJid jid of a resource
|
||||
* @return true if resource supports OMEMO
|
||||
* @throws XMPPException.XMPPErrorException if
|
||||
* @throws SmackException.NotConnectedException something
|
||||
* @throws InterruptedException goes
|
||||
* @throws SmackException.NoResponseException wrong
|
||||
*/
|
||||
public boolean resourceSupportsOmemo(FullJid fullJid) throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, InterruptedException, SmackException.NoResponseException {
|
||||
if (fullJid.hasNoResource()) {
|
||||
throw new IllegalArgumentException("Jid " + fullJid + " has no resource part.");
|
||||
}
|
||||
return ServiceDiscoveryManager.getInstanceFor(connection()).discoverInfo(fullJid).containsFeature(PEP_NODE_DEVICE_LIST_NOTIFY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true, if the MUC with the EntityBareJid multiUserChat is non-anonymous and members only (prerequisite
|
||||
* for OMEMO encryption in MUC).
|
||||
|
@ -641,54 +643,6 @@ public final class OmemoManager extends Manager {
|
|||
}
|
||||
}
|
||||
|
||||
private void setConnectionListener() {
|
||||
connection().addConnectionListener(new ConnectionListener() {
|
||||
@Override
|
||||
public void connected(XMPPConnection connection) {
|
||||
LOGGER.log(Level.INFO, "connected");
|
||||
}
|
||||
|
||||
@Override
|
||||
public void authenticated(XMPPConnection connection, boolean resumed) {
|
||||
LOGGER.log(Level.INFO, "authenticated. Resumed: " + resumed);
|
||||
if (resumed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
initialize();
|
||||
} catch (InterruptedException | CorruptedOmemoKeyException | PubSubException.NotALeafNodeException | SmackException.NotLoggedInException | SmackException.NoResponseException | SmackException.NotConnectedException | XMPPException.XMPPErrorException e) {
|
||||
LOGGER.log(Level.SEVERE, "connectionListener.authenticated() failed to initialize OmemoManager: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connectionClosed() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void connectionClosedOnError(Exception e) {
|
||||
connectionClosed();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reconnectionSuccessful() {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reconnectingIn(int seconds) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reconnectionFailed(Exception e) {
|
||||
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public static int randomDeviceId() {
|
||||
int i = new Random().nextInt(Integer.MAX_VALUE);
|
||||
|
||||
|
|
|
@ -39,7 +39,6 @@ import java.util.Random;
|
|||
import java.util.Set;
|
||||
import java.util.logging.Level;
|
||||
import java.util.logging.Logger;
|
||||
|
||||
import javax.crypto.BadPaddingException;
|
||||
import javax.crypto.IllegalBlockSizeException;
|
||||
import javax.crypto.NoSuchPaddingException;
|
||||
|
@ -51,6 +50,7 @@ import org.jivesoftware.smack.filter.StanzaFilter;
|
|||
import org.jivesoftware.smack.packet.Message;
|
||||
import org.jivesoftware.smack.packet.Stanza;
|
||||
import org.jivesoftware.smack.packet.XMPPError;
|
||||
import org.jivesoftware.smack.util.Async;
|
||||
|
||||
import org.jivesoftware.smackx.carbons.CarbonCopyReceivedListener;
|
||||
import org.jivesoftware.smackx.carbons.CarbonManager;
|
||||
|
@ -82,12 +82,13 @@ import org.jivesoftware.smackx.omemo.util.OmemoMessageBuilder;
|
|||
import org.jivesoftware.smackx.pep.PEPManager;
|
||||
import org.jivesoftware.smackx.pubsub.LeafNode;
|
||||
import org.jivesoftware.smackx.pubsub.PayloadItem;
|
||||
import org.jivesoftware.smackx.pubsub.PubSubAssertionError;
|
||||
import org.jivesoftware.smackx.pubsub.PubSubException;
|
||||
import org.jivesoftware.smackx.pubsub.PubSubException.NotAPubSubNodeException;
|
||||
import org.jivesoftware.smackx.pubsub.PubSubManager;
|
||||
|
||||
import org.bouncycastle.jce.provider.BouncyCastleProvider;
|
||||
import org.jxmpp.jid.BareJid;
|
||||
import org.jxmpp.jid.Jid;
|
||||
|
||||
/**
|
||||
* This class contains OMEMO related logic and registers listeners etc.
|
||||
|
@ -427,11 +428,11 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
* @throws XMPPException.XMPPErrorException
|
||||
* @throws SmackException.NotConnectedException
|
||||
* @throws SmackException.NoResponseException
|
||||
* @throws PubSubAssertionError.DiscoInfoNodeAssertionError ejabberd bug: https://github.com/processone/ejabberd/issues/1717
|
||||
* @throws NotAPubSubNodeException
|
||||
*/
|
||||
static LeafNode fetchDeviceListNode(OmemoManager omemoManager, BareJid contact)
|
||||
throws InterruptedException, PubSubException.NotALeafNodeException, XMPPException.XMPPErrorException,
|
||||
SmackException.NotConnectedException, SmackException.NoResponseException, PubSubAssertionError.DiscoInfoNodeAssertionError {
|
||||
SmackException.NotConnectedException, SmackException.NoResponseException, NotAPubSubNodeException {
|
||||
return PubSubManager.getInstance(omemoManager.getConnection(), contact).getLeafNode(PEP_NODE_DEVICE_LIST);
|
||||
}
|
||||
|
||||
|
@ -446,9 +447,11 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
* @throws InterruptedException goes
|
||||
* @throws SmackException.NoResponseException wrong
|
||||
* @throws PubSubException.NotALeafNodeException when the device lists node is not a LeafNode
|
||||
* @throws PubSubAssertionError.DiscoInfoNodeAssertionError ejabberd bug: https://github.com/processone/ejabberd/issues/1717
|
||||
* @throws NotAPubSubNodeException
|
||||
*/
|
||||
static OmemoDeviceListElement fetchDeviceList(OmemoManager omemoManager, BareJid contact) throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, InterruptedException, SmackException.NoResponseException, PubSubException.NotALeafNodeException, PubSubAssertionError.DiscoInfoNodeAssertionError {
|
||||
static OmemoDeviceListElement fetchDeviceList(OmemoManager omemoManager, BareJid contact)
|
||||
throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, InterruptedException,
|
||||
SmackException.NoResponseException, PubSubException.NotALeafNodeException, NotAPubSubNodeException {
|
||||
return extractDeviceListFrom(fetchDeviceListNode(omemoManager, contact));
|
||||
}
|
||||
|
||||
|
@ -482,9 +485,9 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
e.getMessage());
|
||||
}
|
||||
|
||||
catch (PubSubAssertionError.DiscoInfoNodeAssertionError bug) {
|
||||
catch (PubSubException.NotAPubSubNodeException e) {
|
||||
LOGGER.log(Level.WARNING, "Caught a PubSubAssertionError when fetching a deviceList node. " +
|
||||
"This probably means that we're dealing with an ejabberd server and the LeafNode does not exist.");
|
||||
"This probably means that we're dealing with an ejabberd server and the LeafNode does not exist.", e);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
|
@ -506,9 +509,8 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
LOGGER.log(Level.WARNING, "Could not fetch device list of " + contact + ": " + e, e);
|
||||
return;
|
||||
}
|
||||
catch (PubSubAssertionError.DiscoInfoNodeAssertionError bug) {
|
||||
LOGGER.log(Level.WARNING, "Caught a PubSubAssertionError when fetching a deviceList node. " +
|
||||
"This probably means that the LeafNode does not exist.");
|
||||
catch (NotAPubSubNodeException e) {
|
||||
LOGGER.log(Level.WARNING, "Could not fetch device list of " + contact ,e);
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -526,9 +528,13 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
* @throws InterruptedException goes
|
||||
* @throws SmackException.NoResponseException wrong
|
||||
* @throws PubSubException.NotALeafNodeException when the bundles node is not a LeafNode
|
||||
* @throws NotAPubSubNodeException
|
||||
*/
|
||||
static OmemoBundleVAxolotlElement fetchBundle(OmemoManager omemoManager, OmemoDevice contact) throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, InterruptedException, SmackException.NoResponseException, PubSubException.NotALeafNodeException, PubSubAssertionError.DiscoInfoNodeAssertionError {
|
||||
LeafNode node = PubSubManager.getInstance(omemoManager.getConnection(), contact.getJid()).getLeafNode(PEP_NODE_BUNDLE_FROM_DEVICE_ID(contact.getDeviceId()));
|
||||
static OmemoBundleVAxolotlElement fetchBundle(OmemoManager omemoManager, OmemoDevice contact)
|
||||
throws XMPPException.XMPPErrorException, SmackException.NotConnectedException, InterruptedException,
|
||||
SmackException.NoResponseException, PubSubException.NotALeafNodeException, NotAPubSubNodeException {
|
||||
LeafNode node = PubSubManager.getInstance(omemoManager.getConnection(), contact.getJid()).getLeafNode(
|
||||
PEP_NODE_BUNDLE_FROM_DEVICE_ID(contact.getDeviceId()));
|
||||
return extractBundleFrom(node);
|
||||
}
|
||||
|
||||
|
@ -665,7 +671,7 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
OmemoBundleVAxolotlElement bundle;
|
||||
try {
|
||||
bundle = fetchBundle(omemoManager, device);
|
||||
} catch (SmackException | XMPPException.XMPPErrorException | InterruptedException | PubSubAssertionError e) {
|
||||
} catch (SmackException | XMPPException.XMPPErrorException | InterruptedException e) {
|
||||
throw new CannotEstablishOmemoSessionException(device, e);
|
||||
}
|
||||
|
||||
|
@ -1177,7 +1183,7 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
*/
|
||||
private static OmemoDevice getSender(OmemoManager omemoManager, Stanza stanza) {
|
||||
OmemoElement omemoElement = stanza.getExtension(OmemoElement.ENCRYPTED, OMEMO_NAMESPACE_V_AXOLOTL);
|
||||
BareJid sender = stanza.getFrom().asBareJid();
|
||||
Jid sender = stanza.getFrom();
|
||||
if (isMucMessage(omemoManager, stanza)) {
|
||||
MultiUserChatManager mucm = MultiUserChatManager.getInstanceFor(omemoManager.getConnection());
|
||||
MultiUserChat muc = mucm.getMultiUserChat(sender.asEntityBareJidIfPossible());
|
||||
|
@ -1186,7 +1192,7 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
if (sender == null) {
|
||||
throw new AssertionError("Sender is null.");
|
||||
}
|
||||
return new OmemoDevice(sender, omemoElement.getHeader().getSid());
|
||||
return new OmemoDevice(sender.asBareJid(), omemoElement.getHeader().getSid());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1309,7 +1315,7 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
@Override
|
||||
public void onCarbonCopyReceived(CarbonExtension.Direction direction, Message carbonCopy, Message wrappingMessage) {
|
||||
if (filter.accept(carbonCopy)) {
|
||||
OmemoDevice senderDevice = getSender(omemoManager, carbonCopy);
|
||||
final OmemoDevice senderDevice = getSender(omemoManager, carbonCopy);
|
||||
Message decrypted;
|
||||
MultiUserChatManager mucm = MultiUserChatManager.getInstanceFor(omemoManager.getConnection());
|
||||
OmemoElement omemoMessage = carbonCopy.getExtension(OmemoElement.ENCRYPTED, OMEMO_NAMESPACE_V_AXOLOTL);
|
||||
|
@ -1365,16 +1371,22 @@ public abstract class OmemoService<T_IdKeyPair, T_IdKey, T_PreKey, T_SigPreKey,
|
|||
LOGGER.log(Level.WARNING, "internal omemoMessageListener failed to decrypt incoming OMEMO carbon copy: "
|
||||
+ e.getMessage());
|
||||
|
||||
} catch (NoRawSessionException e) {
|
||||
try {
|
||||
LOGGER.log(Level.INFO, "Received OMEMO carbon copy message with invalid session from " +
|
||||
senderDevice + ". Send RatchetUpdateMessage.");
|
||||
service.sendOmemoRatchetUpdateMessage(omemoManager, senderDevice, true);
|
||||
} catch (final NoRawSessionException e) {
|
||||
Async.go(new Runnable() {
|
||||
@Override
|
||||
public void run() {
|
||||
try {
|
||||
LOGGER.log(Level.INFO, "Received OMEMO carbon copy message with invalid session from " +
|
||||
senderDevice + ". Send RatchetUpdateMessage.");
|
||||
service.sendOmemoRatchetUpdateMessage(omemoManager, senderDevice, true);
|
||||
|
||||
} catch (UndecidedOmemoIdentityException | CorruptedOmemoKeyException | CannotEstablishOmemoSessionException | CryptoFailedException e1) {
|
||||
LOGGER.log(Level.WARNING, "internal omemoMessageListener failed to establish a session for incoming OMEMO carbon message: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
} catch (UndecidedOmemoIdentityException | CorruptedOmemoKeyException | CannotEstablishOmemoSessionException | CryptoFailedException e1) {
|
||||
LOGGER.log(Level.WARNING, "internal omemoMessageListener failed to establish a session for incoming OMEMO carbon message: "
|
||||
+ e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -35,20 +35,20 @@ public class UndecidedOmemoIdentityException extends Exception {
|
|||
}
|
||||
|
||||
/**
|
||||
* Return the HashSet of untrusted devices.
|
||||
* Return the HashSet of undecided devices.
|
||||
*
|
||||
* @return untrusted devices
|
||||
* @return undecided devices
|
||||
*/
|
||||
public HashSet<OmemoDevice> getUntrustedDevices() {
|
||||
public HashSet<OmemoDevice> getUndecidedDevices() {
|
||||
return this.devices;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add all untrusted devices of another Exception to this Exceptions HashSet of untrusted devices.
|
||||
* Add all undecided devices of another Exception to this Exceptions HashSet of undecided devices.
|
||||
*
|
||||
* @param other other Exception
|
||||
*/
|
||||
public void join(UndecidedOmemoIdentityException other) {
|
||||
this.devices.addAll(other.getUntrustedDevices());
|
||||
this.devices.addAll(other.getUndecidedDevices());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -45,18 +45,18 @@ public class OmemoExceptionsTest {
|
|||
OmemoDevice mallory = new OmemoDevice(JidCreate.bareFrom("mallory@server.tld"), 9876);
|
||||
|
||||
UndecidedOmemoIdentityException u = new UndecidedOmemoIdentityException(alice);
|
||||
assertTrue(u.getUntrustedDevices().contains(alice));
|
||||
assertTrue(u.getUntrustedDevices().size() == 1);
|
||||
assertTrue(u.getUndecidedDevices().contains(alice));
|
||||
assertTrue(u.getUndecidedDevices().size() == 1);
|
||||
|
||||
UndecidedOmemoIdentityException v = new UndecidedOmemoIdentityException(bob);
|
||||
v.getUntrustedDevices().add(mallory);
|
||||
assertTrue(v.getUntrustedDevices().size() == 2);
|
||||
assertTrue(v.getUntrustedDevices().contains(bob));
|
||||
assertTrue(v.getUntrustedDevices().contains(mallory));
|
||||
v.getUndecidedDevices().add(mallory);
|
||||
assertTrue(v.getUndecidedDevices().size() == 2);
|
||||
assertTrue(v.getUndecidedDevices().contains(bob));
|
||||
assertTrue(v.getUndecidedDevices().contains(mallory));
|
||||
|
||||
u.getUntrustedDevices().add(bob);
|
||||
u.getUndecidedDevices().add(bob);
|
||||
u.join(v);
|
||||
assertTrue(u.getUntrustedDevices().size() == 3);
|
||||
assertTrue(u.getUndecidedDevices().size() == 3);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue