diff --git a/README.md b/README.md
index 22ff2a9..80bd162 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ This platform will be organized by many independent servers, each of which will
- [x] Private message between users in a server. **No support for private messaging users outside the context of a server.**
- [ ] Banning users from the server.
- [ ] Voice channels.
-- [ ] Persistent users. Connect and disconnect from a server multiple times, while keeping your information intact.
+- [x] Persistent users. Connect and disconnect from a server multiple times, while keeping your information intact.
Here's a short demonstration of its current features:
diff --git a/client/pom.xml b/client/pom.xml
index be7bf11..7a53d35 100644
--- a/client/pom.xml
+++ b/client/pom.xml
@@ -33,6 +33,25 @@
jna-platform
5.9.0
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.12.4
+
+
+
+ com.fasterxml.jackson.core
+ jackson-core
+ 2.12.4
+
+
+
+ com.fasterxml.jackson.core
+ jackson-annotations
+ 2.12.4
+
diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java
index bca82e3..1928370 100644
--- a/client/src/main/java/module-info.java
+++ b/client/src/main/java/module-info.java
@@ -4,4 +4,7 @@ module concord_client {
requires com.sun.jna;
requires com.sun.jna.platform;
requires static lombok;
+ requires com.fasterxml.jackson.databind;
+ requires com.fasterxml.jackson.core;
+ requires com.fasterxml.jackson.annotation;
}
\ No newline at end of file
diff --git a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java
index 2c52bc5..403c89c 100644
--- a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java
+++ b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java
@@ -1,5 +1,6 @@
package nl.andrewl.concord_client;
+import com.fasterxml.jackson.databind.ObjectMapper;
import com.googlecode.lanterna.gui2.MultiWindowTextGUI;
import com.googlecode.lanterna.gui2.Window;
import com.googlecode.lanterna.gui2.WindowBasedTextGUI;
@@ -23,7 +24,11 @@ import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.net.Socket;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.HashMap;
import java.util.List;
+import java.util.Map;
public class ConcordClient implements Runnable {
private final Socket socket;
@@ -38,13 +43,13 @@ public class ConcordClient implements Runnable {
private volatile boolean running;
- public ConcordClient(String host, int port, String nickname) throws IOException {
+ public ConcordClient(String host, int port, String nickname, Path tokensFile) throws IOException {
this.eventManager = new EventManager(this);
this.socket = new Socket(host, port);
this.in = new DataInputStream(this.socket.getInputStream());
this.out = new DataOutputStream(this.socket.getOutputStream());
this.serializer = new Serializer();
- this.model = this.initializeConnectionToServer(nickname);
+ this.model = this.initializeConnectionToServer(nickname, tokensFile);
// Add event listeners.
this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler());
@@ -61,16 +66,19 @@ public class ConcordClient implements Runnable {
* placed in by the server.
* @param nickname The nickname to send to the server that it should know
* us by.
+ * @param tokensFile Path to the file where session tokens are stored.
* @return The client model that contains the server's metadata and other
* information that should be kept up-to-date at runtime.
* @throws IOException If an error occurs while reading or writing the
* messages, or if the server sends an unexpected response.
*/
- private ClientModel initializeConnectionToServer(String nickname) throws IOException {
- this.serializer.writeMessage(new Identification(nickname), this.out);
+ private ClientModel initializeConnectionToServer(String nickname, Path tokensFile) throws IOException {
+ String token = this.getSessionToken(tokensFile);
+ this.serializer.writeMessage(new Identification(nickname, token), this.out);
Message reply = this.serializer.readMessage(this.in);
if (reply instanceof ServerWelcome welcome) {
var model = new ClientModel(welcome.getClientId(), nickname, welcome.getCurrentChannelId(), welcome.getCurrentChannelName(), welcome.getMetaData());
+ this.saveSessionToken(welcome.getSessionToken(), tokensFile);
// Start fetching initial data for the channel we were initially put into.
this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
return model;
@@ -117,6 +125,44 @@ public class ConcordClient implements Runnable {
}
}
+ /**
+ * Fetches the session token that this client should use for its currently
+ * configured server, according to the socket address and port.
+ * @param tokensFile The file containing the session tokens.
+ * @return The session token, or null if none was found.
+ * @throws IOException If the tokens file could not be read.
+ */
+ @SuppressWarnings("unchecked")
+ private String getSessionToken(Path tokensFile) throws IOException {
+ String token = null;
+ String address = this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort();
+ if (Files.exists(tokensFile)) {
+ ObjectMapper mapper = new ObjectMapper();
+ Map sessionTokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
+ token = sessionTokens.get(address);
+ }
+ return token;
+ }
+
+ /**
+ * Saves a session token that this client should use the next time it
+ * connects to the same server.
+ * @param token The token to save.
+ * @param tokensFile The file containing the session tokens.
+ * @throws IOException If the tokens file could not be read or written to.
+ */
+ @SuppressWarnings("unchecked")
+ private void saveSessionToken(String token, Path tokensFile) throws IOException {
+ String address = this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort();
+ Map tokens = new HashMap<>();
+ ObjectMapper mapper = new ObjectMapper();
+ if (Files.exists(tokensFile)) {
+ tokens = mapper.readValue(Files.newBufferedReader(tokensFile), Map.class);
+ }
+ tokens.put(address, token);
+ mapper.writerWithDefaultPrettyPrinter().writeValue(Files.newBufferedWriter(tokensFile), tokens);
+ }
+
public static void main(String[] args) throws IOException {
diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java
index 5e02783..67a3a9f 100644
--- a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java
+++ b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java
@@ -6,6 +6,7 @@ import com.googlecode.lanterna.input.KeyStroke;
import nl.andrewl.concord_client.ConcordClient;
import java.io.IOException;
+import java.nio.file.Path;
import java.util.concurrent.atomic.AtomicBoolean;
public class MainWindow extends BasicWindow {
@@ -49,7 +50,7 @@ public class MainWindow extends BasicWindow {
if (nickname == null) return;
try {
- var client = new ConcordClient(host, port, nickname);
+ var client = new ConcordClient(host, port, nickname, Path.of("concord-session-tokens.json"));
var chatPanel = new ServerPanel(client, this);
client.getModel().addListener(chatPanel);
new Thread(client).start();
diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java
index 7a5d9a6..e36ab54 100644
--- a/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java
+++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java
@@ -1,39 +1,56 @@
package nl.andrewl.concord_core.msg.types;
+import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import nl.andrewl.concord_core.msg.Message;
-import nl.andrewl.concord_core.msg.MessageUtils;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
+import static nl.andrewl.concord_core.msg.MessageUtils.*;
+
/**
* This message is sent from the client to a server, to provide identification
* information about the client to the server when the connection is started.
*/
@Data
+@AllArgsConstructor
@NoArgsConstructor
public class Identification implements Message {
+ /**
+ * The nickname that a client wants to be identified by when in the server.
+ * If a valid session token is provided, this can be left as null, and the
+ * user will be given the same nickname they had in their previous session.
+ */
private String nickname;
+ /**
+ * A session token that's used to uniquely identify this client as the same
+ * as one who has previously connected to the server. If this is null, the
+ * client is indicating that they have not connected to this server before.
+ */
+ private String sessionToken;
+
public Identification(String nickname) {
this.nickname = nickname;
}
@Override
public int getByteCount() {
- return MessageUtils.getByteSize(this.nickname);
+ return getByteSize(this.nickname) + getByteSize(sessionToken);
}
@Override
public void write(DataOutputStream o) throws IOException {
- MessageUtils.writeString(this.nickname, o);
+ writeString(this.nickname, o);
+ writeString(this.sessionToken, o);
}
@Override
public void read(DataInputStream i) throws IOException {
- this.nickname = MessageUtils.readString(i);
+ this.nickname = readString(i);
+ this.sessionToken = readString(i);
}
}
diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java
index 17864b6..293e05d 100644
--- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java
+++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java
@@ -20,19 +20,41 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*;
@NoArgsConstructor
@AllArgsConstructor
public class ServerWelcome implements Message {
+ /**
+ * The unique id of this client.
+ */
private UUID clientId;
+
+ /**
+ * The token which this client can use to reconnect to the server later and
+ * still be recognized as the same user.
+ */
+ private String sessionToken;
+
+ /**
+ * The id of the channel that the user has been placed in.
+ */
private UUID currentChannelId;
+
+ /**
+ * The name of the channel that the user has been placed in.
+ */
private String currentChannelName;
+
+ /**
+ * Information about the server's structure.
+ */
private ServerMetaData metaData;
@Override
public int getByteCount() {
- return 2 * UUID_BYTES + getByteSize(this.currentChannelName) + this.metaData.getByteCount();
+ return 2 * UUID_BYTES + getByteSize(this.sessionToken) + getByteSize(this.currentChannelName) + this.metaData.getByteCount();
}
@Override
public void write(DataOutputStream o) throws IOException {
writeUUID(this.clientId, o);
+ writeString(this.sessionToken, o);
writeUUID(this.currentChannelId, o);
writeString(this.currentChannelName, o);
this.metaData.write(o);
@@ -41,6 +63,7 @@ public class ServerWelcome implements Message {
@Override
public void read(DataInputStream i) throws IOException {
this.clientId = readUUID(i);
+ this.sessionToken = readString(i);
this.currentChannelId = readUUID(i);
this.metaData = new ServerMetaData();
this.currentChannelName = readString(i);
diff --git a/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java b/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java
index 5f90ec0..50b9ae3 100644
--- a/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java
+++ b/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java
@@ -49,31 +49,19 @@ public class Channel {
}
/**
- * Adds a client to this channel. Also sends an update to all clients,
- * including the new one, telling them that a user has joined.
+ * Adds a client to this channel.
* @param clientThread The client to add.
*/
public void addClient(ClientThread clientThread) {
this.connectedClients.add(clientThread);
-// try {
-// this.sendMessage(new ChannelUsersResponse(this.getUserData()));
-// } catch (IOException e) {
-// e.printStackTrace();
-// }
}
/**
- * Removes a client from this channel. Also sends an update to all the
- * clients that are still connected, telling them that a user has left.
+ * Removes a client from this channel.
* @param clientThread The client to remove.
*/
public void removeClient(ClientThread clientThread) {
this.connectedClients.remove(clientThread);
-// try {
-// this.sendMessage(new ChannelUsersResponse(this.getUserData()));
-// } catch (IOException e) {
-// e.printStackTrace();
-// }
}
/**
diff --git a/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java
index 44cbb28..0eda45c 100644
--- a/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java
+++ b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java
@@ -1,11 +1,15 @@
package nl.andrewl.concord_server.client;
import nl.andrewl.concord_core.msg.Message;
-import nl.andrewl.concord_core.msg.types.Identification;
-import nl.andrewl.concord_core.msg.types.ServerUsers;
-import nl.andrewl.concord_core.msg.types.ServerWelcome;
-import nl.andrewl.concord_core.msg.types.UserData;
+import nl.andrewl.concord_core.msg.types.Error;
+import nl.andrewl.concord_core.msg.types.*;
import nl.andrewl.concord_server.ConcordServer;
+import nl.andrewl.concord_server.util.CollectionUtils;
+import nl.andrewl.concord_server.util.StringUtils;
+import org.dizitart.no2.Document;
+import org.dizitart.no2.IndexType;
+import org.dizitart.no2.NitriteCollection;
+import org.dizitart.no2.filters.Filters;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -20,10 +24,17 @@ import java.util.stream.Collectors;
public class ClientManager {
private final ConcordServer server;
private final Map clients;
+ private final NitriteCollection userCollection;
public ClientManager(ConcordServer server) {
this.server = server;
this.clients = new ConcurrentHashMap<>();
+ this.userCollection = server.getDb().getCollection("users");
+ CollectionUtils.ensureIndexes(this.userCollection, Map.of(
+ "id", IndexType.Unique,
+ "sessionToken", IndexType.Unique,
+ "nickname", IndexType.Fulltext
+ ));
}
/**
@@ -36,18 +47,29 @@ public class ClientManager {
* @param clientThread The client manager thread.
*/
public void registerClient(Identification identification, ClientThread clientThread) {
- var id = this.server.getIdProvider().newId();
- System.out.printf("Client \"%s\" joined with id %s.\n", identification.getNickname(), id);
- this.clients.put(id, clientThread);
- clientThread.setClientId(id);
- clientThread.setClientNickname(identification.getNickname());
- // Immediately add the client to the default channel and send the initial welcome message.
+ ClientConnectionData data;
+ try {
+ data = identification.getSessionToken() == null ? getNewClientData(identification) : getClientDataFromDb(identification);
+ } catch (InvalidIdentificationException e) {
+ clientThread.sendToClient(Error.warning(e.getMessage()));
+ return;
+ }
+
+ this.clients.put(data.id, clientThread);
+ clientThread.setClientId(data.id);
+ clientThread.setClientNickname(data.nickname);
var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow();
- clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData()));
+ clientThread.sendToClient(new ServerWelcome(data.id, data.sessionToken, defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData()));
// It is important that we send the welcome message first. The client expects this as the initial response to their identification message.
defaultChannel.addClient(clientThread);
clientThread.setCurrentChannel(defaultChannel);
- System.out.println("Moved client " + clientThread + " to " + defaultChannel);
+ System.out.printf(
+ "Client %s(%s) joined%s, and was put into %s.\n",
+ data.nickname,
+ data.id,
+ data.newClient ? " for the first time" : "",
+ defaultChannel
+ );
this.broadcast(new ServerUsers(this.getClients()));
}
@@ -98,4 +120,43 @@ public class ClientManager {
public Optional getClientById(UUID id) {
return Optional.ofNullable(this.clients.get(id));
}
+
+ private static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {}
+
+ private ClientConnectionData getClientDataFromDb(Identification identification) throws InvalidIdentificationException {
+ var cursor = this.userCollection.find(Filters.eq("sessionToken", identification.getSessionToken()));
+ Document doc = cursor.firstOrDefault();
+ if (doc != null) {
+ UUID id = doc.get("id", UUID.class);
+ String nickname = identification.getNickname();
+ if (nickname != null) {
+ doc.put("nickname", nickname);
+ } else {
+ nickname = doc.get("nickname", String.class);
+ }
+ String sessionToken = StringUtils.random(128);
+ doc.put("sessionToken", sessionToken);
+ this.userCollection.update(doc);
+ return new ClientConnectionData(id, nickname, sessionToken, false);
+ } else {
+ throw new InvalidIdentificationException("Invalid session token.");
+ }
+ }
+
+ private ClientConnectionData getNewClientData(Identification identification) throws InvalidIdentificationException {
+ UUID id = this.server.getIdProvider().newId();
+ String nickname = identification.getNickname();
+ if (nickname == null) {
+ throw new InvalidIdentificationException("Missing nickname.");
+ }
+ String sessionToken = StringUtils.random(128);
+ Document doc = new Document(Map.of(
+ "id", id,
+ "nickname", nickname,
+ "sessionToken", sessionToken,
+ "createdAt", System.currentTimeMillis()
+ ));
+ this.userCollection.insert(doc);
+ return new ClientConnectionData(id, nickname, sessionToken, true);
+ }
}
diff --git a/server/src/main/java/nl/andrewl/concord_server/client/InvalidIdentificationException.java b/server/src/main/java/nl/andrewl/concord_server/client/InvalidIdentificationException.java
new file mode 100644
index 0000000..ffc45ad
--- /dev/null
+++ b/server/src/main/java/nl/andrewl/concord_server/client/InvalidIdentificationException.java
@@ -0,0 +1,10 @@
+package nl.andrewl.concord_server.client;
+
+/**
+ * Exception that's thrown when a client's identification information is invalid.
+ */
+public class InvalidIdentificationException extends Exception {
+ public InvalidIdentificationException(String message) {
+ super(message);
+ }
+}
diff --git a/server/src/main/java/nl/andrewl/concord_server/util/Pair.java b/server/src/main/java/nl/andrewl/concord_server/util/Pair.java
new file mode 100644
index 0000000..ddce77d
--- /dev/null
+++ b/server/src/main/java/nl/andrewl/concord_server/util/Pair.java
@@ -0,0 +1,3 @@
+package nl.andrewl.concord_server.util;
+
+public record Pair(A first, B second) {}
diff --git a/server/src/main/java/nl/andrewl/concord_server/util/StringUtils.java b/server/src/main/java/nl/andrewl/concord_server/util/StringUtils.java
new file mode 100644
index 0000000..b74e232
--- /dev/null
+++ b/server/src/main/java/nl/andrewl/concord_server/util/StringUtils.java
@@ -0,0 +1,17 @@
+package nl.andrewl.concord_server.util;
+
+import java.security.SecureRandom;
+import java.util.Random;
+
+public class StringUtils {
+
+ public static String random(int length) {
+ Random random = new SecureRandom();
+ final String alphabet = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-=+[]{}()<>";
+ StringBuilder sb = new StringBuilder(length);
+ for (int i = 0; i < length; i++) {
+ sb.append(alphabet.charAt(random.nextInt(alphabet.length())));
+ }
+ return sb.toString();
+ }
+}