Merge pull request #7 from andrewlalis/userRegistration
User registration
This commit is contained in:
		
						commit
						108cc556d7
					
				| 
						 | 
					@ -38,6 +38,7 @@ You probably want to customize your server a bit. To do so, first stop your serv
 | 
				
			||||||
- `name` The name of the server.
 | 
					- `name` The name of the server.
 | 
				
			||||||
- `description` A short description of what this server is for, or who it's run by.
 | 
					- `description` A short description of what this server is for, or who it's run by.
 | 
				
			||||||
- `port` The port on which the server accepts client connections.
 | 
					- `port` The port on which the server accepts client connections.
 | 
				
			||||||
 | 
					- `acceptAllNewClients` Whether to automatically accept any new client that registers to this server. Set to false by default, meaning an administrator needs to approve any pending registration before it is complete.
 | 
				
			||||||
- `chatHistoryMaxCount` The maximum amount of chat messages that a client can request from the server at any given time. Decrease this to improve performance.
 | 
					- `chatHistoryMaxCount` The maximum amount of chat messages that a client can request from the server at any given time. Decrease this to improve performance.
 | 
				
			||||||
- `chatHistoryDefaultCount` The default number of chat messages that are provided to clients when they join a channel, if they don't explicitly request a certain amount. Decrease this to improve performance.
 | 
					- `chatHistoryDefaultCount` The default number of chat messages that are provided to clients when they join a channel, if they don't explicitly request a certain amount. Decrease this to improve performance.
 | 
				
			||||||
- `maxMessageLength` The maximum length of a message. Messages longer than this will be rejected.
 | 
					- `maxMessageLength` The maximum length of a message. Messages longer than this will be rejected.
 | 
				
			||||||
| 
						 | 
					@ -46,11 +47,6 @@ You probably want to customize your server a bit. To do so, first stop your serv
 | 
				
			||||||
 | 
					
 | 
				
			||||||
## Server CLI
 | 
					## Server CLI
 | 
				
			||||||
 | 
					
 | 
				
			||||||
As mentioned briefly, the server supports a basic command-line-interface with some commands. You can show which commands are available via the `help` command. The following is a list of some of the most useful commands and a description of their functionality:
 | 
					As mentioned briefly, the server supports a basic command-line-interface with some commands. You can show the commands that are available via the `help` command.
 | 
				
			||||||
 | 
					 | 
				
			||||||
- `add-channel <name>` Adds a new channel to the server with the given name. Channel names cannot be blank, and they cannot be duplicates of an existing channel name.
 | 
					 | 
				
			||||||
- `remove-channel <name>` Removes a channel.
 | 
					 | 
				
			||||||
- `list-clients` Shows a list of all connected clients.
 | 
					 | 
				
			||||||
- `stop` Stops the server, disconnecting all clients.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information.
 | 
					Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,6 +1,5 @@
 | 
				
			||||||
package nl.andrewl.concord_client;
 | 
					package nl.andrewl.concord_client;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import com.fasterxml.jackson.databind.ObjectMapper;
 | 
					 | 
				
			||||||
import com.googlecode.lanterna.gui2.MultiWindowTextGUI;
 | 
					import com.googlecode.lanterna.gui2.MultiWindowTextGUI;
 | 
				
			||||||
import com.googlecode.lanterna.gui2.Window;
 | 
					import com.googlecode.lanterna.gui2.Window;
 | 
				
			||||||
import com.googlecode.lanterna.gui2.WindowBasedTextGUI;
 | 
					import com.googlecode.lanterna.gui2.WindowBasedTextGUI;
 | 
				
			||||||
| 
						 | 
					@ -9,6 +8,8 @@ import com.googlecode.lanterna.screen.TerminalScreen;
 | 
				
			||||||
import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
 | 
					import com.googlecode.lanterna.terminal.DefaultTerminalFactory;
 | 
				
			||||||
import com.googlecode.lanterna.terminal.Terminal;
 | 
					import com.googlecode.lanterna.terminal.Terminal;
 | 
				
			||||||
import lombok.Getter;
 | 
					import lombok.Getter;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_client.data.ClientDataStore;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_client.data.JsonClientDataStore;
 | 
				
			||||||
import nl.andrewl.concord_client.event.EventManager;
 | 
					import nl.andrewl.concord_client.event.EventManager;
 | 
				
			||||||
import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler;
 | 
					import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler;
 | 
				
			||||||
import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler;
 | 
					import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler;
 | 
				
			||||||
| 
						 | 
					@ -19,42 +20,41 @@ import nl.andrewl.concord_client.model.ClientModel;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.Encryption;
 | 
					import nl.andrewl.concord_core.msg.Encryption;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.Message;
 | 
					import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.Serializer;
 | 
					import nl.andrewl.concord_core.msg.Serializer;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.*;
 | 
					import nl.andrewl.concord_core.msg.types.ServerMetaData;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.ServerUsers;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
 | 
					import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.chat.Chat;
 | 
					import nl.andrewl.concord_core.msg.types.chat.Chat;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
 | 
					import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
 | 
					import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.*;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.io.IOException;
 | 
					import java.io.IOException;
 | 
				
			||||||
import java.io.InputStream;
 | 
					import java.io.InputStream;
 | 
				
			||||||
import java.io.OutputStream;
 | 
					import java.io.OutputStream;
 | 
				
			||||||
import java.net.Socket;
 | 
					import java.net.Socket;
 | 
				
			||||||
import java.nio.file.Files;
 | 
					 | 
				
			||||||
import java.nio.file.Path;
 | 
					import java.nio.file.Path;
 | 
				
			||||||
import java.security.GeneralSecurityException;
 | 
					import java.security.GeneralSecurityException;
 | 
				
			||||||
import java.util.HashMap;
 | 
					 | 
				
			||||||
import java.util.List;
 | 
					import java.util.List;
 | 
				
			||||||
import java.util.Map;
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
public class ConcordClient implements Runnable {
 | 
					public class ConcordClient implements Runnable {
 | 
				
			||||||
	private final Socket socket;
 | 
						private final Socket socket;
 | 
				
			||||||
	private final InputStream in;
 | 
						private final InputStream in;
 | 
				
			||||||
	private final OutputStream out;
 | 
						private final OutputStream out;
 | 
				
			||||||
	private final Serializer serializer;
 | 
						private final Serializer serializer;
 | 
				
			||||||
 | 
						private final ClientDataStore dataStore;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	@Getter
 | 
						@Getter
 | 
				
			||||||
	private final ClientModel model;
 | 
						private ClientModel model;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private final EventManager eventManager;
 | 
						private final EventManager eventManager;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private volatile boolean running;
 | 
						private volatile boolean running;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public ConcordClient(String host, int port, String nickname, Path tokensFile) throws IOException {
 | 
						private ConcordClient(String host, int port) throws IOException {
 | 
				
			||||||
		this.eventManager = new EventManager(this);
 | 
							this.eventManager = new EventManager(this);
 | 
				
			||||||
		this.socket = new Socket(host, port);
 | 
							this.socket = new Socket(host, port);
 | 
				
			||||||
		this.serializer = new Serializer();
 | 
							this.serializer = new Serializer();
 | 
				
			||||||
 | 
							this.dataStore = new JsonClientDataStore(Path.of("concord-session-tokens.json"));
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			var streams = Encryption.upgrade(socket.getInputStream(), socket.getOutputStream(), this.serializer);
 | 
								var streams = Encryption.upgrade(socket.getInputStream(), socket.getOutputStream(), this.serializer);
 | 
				
			||||||
			this.in = streams.first();
 | 
								this.in = streams.first();
 | 
				
			||||||
| 
						 | 
					@ -62,8 +62,6 @@ public class ConcordClient implements Runnable {
 | 
				
			||||||
		} catch (GeneralSecurityException e) {
 | 
							} catch (GeneralSecurityException e) {
 | 
				
			||||||
			throw new IOException("Could not establish secure connection to the server.", e);
 | 
								throw new IOException("Could not establish secure connection to the server.", e);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		this.model = this.initializeConnectionToServer(nickname, tokensFile);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
		// Add event listeners.
 | 
							// Add event listeners.
 | 
				
			||||||
		this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler());
 | 
							this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler());
 | 
				
			||||||
		this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler());
 | 
							this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler());
 | 
				
			||||||
| 
						 | 
					@ -72,32 +70,63 @@ public class ConcordClient implements Runnable {
 | 
				
			||||||
		this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler());
 | 
							this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler());
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						public static ConcordClient register(String host, int port, String username, String password) throws IOException {
 | 
				
			||||||
	 * Initializes the communication with the server by sending an {@link Identification}
 | 
							var client = new ConcordClient(host, port);
 | 
				
			||||||
	 * message, and waiting for a {@link ServerWelcome} response from the
 | 
							client.sendMessage(new ClientRegistration(null, null, username, password));
 | 
				
			||||||
	 * server. After that, we request some information about the channel we were
 | 
							Message reply = client.serializer.readMessage(client.in);
 | 
				
			||||||
	 * placed in by the server.
 | 
							if (reply instanceof RegistrationStatus status) {
 | 
				
			||||||
	 * @param nickname The nickname to send to the server that it should know
 | 
								if (status.type() == RegistrationStatus.Type.ACCEPTED) {
 | 
				
			||||||
	 *                 us by.
 | 
									ServerWelcome welcomeData = (ServerWelcome) client.serializer.readMessage(client.in);
 | 
				
			||||||
	 * @param tokensFile Path to the file where session tokens are stored.
 | 
									client.initializeClientModel(welcomeData, username);
 | 
				
			||||||
	 * @return The client model that contains the server's metadata and other
 | 
								} else if (status.type() == RegistrationStatus.Type.PENDING) {
 | 
				
			||||||
	 * information that should be kept up-to-date at runtime.
 | 
									System.out.println("Registration pending!");
 | 
				
			||||||
	 * @throws IOException If an error occurs while reading or writing the
 | 
								}
 | 
				
			||||||
	 * messages, or if the server sends an unexpected response.
 | 
							} else {
 | 
				
			||||||
	 */
 | 
								System.out.println(reply);
 | 
				
			||||||
	private ClientModel initializeConnectionToServer(String nickname, Path tokensFile) throws IOException {
 | 
							}
 | 
				
			||||||
		String token = this.getSessionToken(tokensFile);
 | 
							return client;
 | 
				
			||||||
		this.serializer.writeMessage(new Identification(nickname, token), this.out);
 | 
						}
 | 
				
			||||||
		Message reply = this.serializer.readMessage(this.in);
 | 
					
 | 
				
			||||||
 | 
						public static ConcordClient login(String host, int port, String username, String password) throws IOException {
 | 
				
			||||||
 | 
							var client = new ConcordClient(host, port);
 | 
				
			||||||
 | 
							client.sendMessage(new ClientLogin(username, password));
 | 
				
			||||||
 | 
							Message reply = client.serializer.readMessage(client.in);
 | 
				
			||||||
		if (reply instanceof ServerWelcome welcome) {
 | 
							if (reply instanceof ServerWelcome welcome) {
 | 
				
			||||||
			var model = new ClientModel(welcome.clientId(), nickname, welcome.currentChannelId(), welcome.currentChannelName(), welcome.metaData());
 | 
								client.initializeClientModel(welcome, username);
 | 
				
			||||||
			this.saveSessionToken(welcome.sessionToken(), tokensFile);
 | 
							} else if (reply instanceof RegistrationStatus status && status.type() == RegistrationStatus.Type.PENDING) {
 | 
				
			||||||
 | 
								System.out.println("Registration pending!");
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								System.out.println(reply);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return client;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public static ConcordClient loginWithToken(String host, int port) throws IOException {
 | 
				
			||||||
 | 
							var client = new ConcordClient(host, port);
 | 
				
			||||||
 | 
							var token = client.dataStore.getSessionToken(client.socket.getInetAddress().getHostName() + ":" + client.socket.getPort());
 | 
				
			||||||
 | 
							if (token.isPresent()) {
 | 
				
			||||||
 | 
								client.sendMessage(new ClientSessionResume(token.get()));
 | 
				
			||||||
 | 
								Message reply = client.serializer.readMessage(client.in);
 | 
				
			||||||
 | 
								if (reply instanceof ServerWelcome welcome) {
 | 
				
			||||||
 | 
									client.initializeClientModel(welcome, "unknown");
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								System.err.println("No session token!");
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return client;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						private void initializeClientModel(ServerWelcome welcomeData, String username) throws IOException {
 | 
				
			||||||
 | 
							var model = new ClientModel(
 | 
				
			||||||
 | 
									welcomeData.clientId(),
 | 
				
			||||||
 | 
									username,
 | 
				
			||||||
 | 
									welcomeData.currentChannelId(),
 | 
				
			||||||
 | 
									welcomeData.currentChannelName(),
 | 
				
			||||||
 | 
									welcomeData.metaData()
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
							this.dataStore.saveSessionToken(this.socket.getInetAddress().getHostName() + ":" + this.socket.getPort(), welcomeData.sessionToken());
 | 
				
			||||||
		// Start fetching initial data for the channel we were initially put into.
 | 
							// Start fetching initial data for the channel we were initially put into.
 | 
				
			||||||
		this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
 | 
							this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), ""));
 | 
				
			||||||
			return model;
 | 
					 | 
				
			||||||
		} else {
 | 
					 | 
				
			||||||
			throw new IOException("Unexpected response from the server after sending identification message: " + reply);
 | 
					 | 
				
			||||||
		}
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public void sendMessage(Message message) throws IOException {
 | 
						public void sendMessage(Message message) throws IOException {
 | 
				
			||||||
| 
						 | 
					@ -138,46 +167,6 @@ 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<String, String> 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<String, String> 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 {
 | 
						public static void main(String[] args) throws IOException {
 | 
				
			||||||
		Terminal term = new DefaultTerminalFactory().createTerminal();
 | 
							Terminal term = new DefaultTerminalFactory().createTerminal();
 | 
				
			||||||
		Screen screen = new TerminalScreen(term);
 | 
							Screen screen = new TerminalScreen(term);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,13 @@
 | 
				
			||||||
 | 
					package nl.andrewl.concord_client.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.io.IOException;
 | 
				
			||||||
 | 
					import java.util.Optional;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * A component which can store and retrieve persistent data which a client can
 | 
				
			||||||
 | 
					 * use as part of its interaction with servers.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public interface ClientDataStore {
 | 
				
			||||||
 | 
						Optional<String> getSessionToken(String serverName) throws IOException;
 | 
				
			||||||
 | 
						void saveSessionToken(String serverName, String sessionToken) throws IOException;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,42 @@
 | 
				
			||||||
 | 
					package nl.andrewl.concord_client.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import com.fasterxml.jackson.databind.ObjectMapper;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.io.IOException;
 | 
				
			||||||
 | 
					import java.nio.file.Files;
 | 
				
			||||||
 | 
					import java.nio.file.Path;
 | 
				
			||||||
 | 
					import java.util.HashMap;
 | 
				
			||||||
 | 
					import java.util.Map;
 | 
				
			||||||
 | 
					import java.util.Optional;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					public class JsonClientDataStore implements ClientDataStore {
 | 
				
			||||||
 | 
						private final Path file;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public JsonClientDataStore(Path file) {
 | 
				
			||||||
 | 
							this.file = file;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Override
 | 
				
			||||||
 | 
						@SuppressWarnings("unchecked")
 | 
				
			||||||
 | 
						public Optional<String> getSessionToken(String serverName) throws IOException {
 | 
				
			||||||
 | 
							String token = null;
 | 
				
			||||||
 | 
							if (Files.exists(file)) {
 | 
				
			||||||
 | 
								ObjectMapper mapper = new ObjectMapper();
 | 
				
			||||||
 | 
								Map<String, String> sessionTokens = mapper.readValue(Files.newBufferedReader(file), Map.class);
 | 
				
			||||||
 | 
								token = sessionTokens.get(serverName);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return Optional.ofNullable(token);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Override
 | 
				
			||||||
 | 
						@SuppressWarnings("unchecked")
 | 
				
			||||||
 | 
						public void saveSessionToken(String serverName, String sessionToken) throws IOException {
 | 
				
			||||||
 | 
							Map<String, String> tokens = new HashMap<>();
 | 
				
			||||||
 | 
							ObjectMapper mapper = new ObjectMapper();
 | 
				
			||||||
 | 
							if (Files.exists(file)) {
 | 
				
			||||||
 | 
								tokens = mapper.readValue(Files.newBufferedReader(file), Map.class);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							tokens.put(serverName, sessionToken);
 | 
				
			||||||
 | 
							mapper.writerWithDefaultPrettyPrinter().writeValue(Files.newBufferedWriter(file), tokens);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -50,7 +50,7 @@ public class MainWindow extends BasicWindow {
 | 
				
			||||||
		if (nickname == null) return;
 | 
							if (nickname == null) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
			var client = new ConcordClient(host, port, nickname, Path.of("concord-session-tokens.json"));
 | 
								var client = ConcordClient.login(host, port, nickname, "testpass");
 | 
				
			||||||
			var chatPanel = new ServerPanel(client, this);
 | 
								var chatPanel = new ServerPanel(client, this);
 | 
				
			||||||
			client.getModel().addListener(chatPanel);
 | 
								client.getModel().addListener(chatPanel);
 | 
				
			||||||
			new Thread(client).start();
 | 
								new Thread(client).start();
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -12,7 +12,7 @@ import java.util.concurrent.CopyOnWriteArrayList;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Getter
 | 
					@Getter
 | 
				
			||||||
public class ClientModel {
 | 
					public class ClientModel {
 | 
				
			||||||
	private UUID id;
 | 
						private final UUID id;
 | 
				
			||||||
	private String nickname;
 | 
						private String nickname;
 | 
				
			||||||
	private ServerMetaData serverMetaData;
 | 
						private ServerMetaData serverMetaData;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,3 +1,12 @@
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * The core components that are used by both the Concord server and the default
 | 
				
			||||||
 | 
					 * client implementation. Includes record-based message serialization, and some
 | 
				
			||||||
 | 
					 * utilities for message passing.
 | 
				
			||||||
 | 
					 * <p>
 | 
				
			||||||
 | 
					 *     This core module defines the message protocol that clients must use to
 | 
				
			||||||
 | 
					 *     communicate with any server.
 | 
				
			||||||
 | 
					 * </p>
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
module concord_core {
 | 
					module concord_core {
 | 
				
			||||||
	requires static lombok;
 | 
						requires static lombok;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,12 +8,22 @@ package nl.andrewl.concord_core.msg;
 | 
				
			||||||
 * </p>
 | 
					 * </p>
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public interface Message {
 | 
					public interface Message {
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Convenience method to get the serializer for this message's type, using
 | 
				
			||||||
 | 
						 * the static auto-generated set of serializers.
 | 
				
			||||||
 | 
						 * @param <T> The message type.
 | 
				
			||||||
 | 
						 * @return The serializer to use to read and write messages of this type.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	@SuppressWarnings("unchecked")
 | 
						@SuppressWarnings("unchecked")
 | 
				
			||||||
	default <T extends Message> MessageType<T> getType() {
 | 
						default <T extends Message> MessageTypeSerializer<T> getTypeSerializer() {
 | 
				
			||||||
		return MessageType.get((Class<T>) this.getClass());
 | 
							return MessageTypeSerializer.get((Class<T>) this.getClass());
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Convenience method to determine the size of this message in bytes.
 | 
				
			||||||
 | 
						 * @return The size of this message, in bytes.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	default int byteSize() {
 | 
						default int byteSize() {
 | 
				
			||||||
		return getType().byteSizeFunction().apply(this);
 | 
							return getTypeSerializer().byteSizeFunction().apply(this);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -19,24 +19,24 @@ import java.util.function.Function;
 | 
				
			||||||
 * @param reader A reader that can read messages from an input stream.
 | 
					 * @param reader A reader that can read messages from an input stream.
 | 
				
			||||||
 * @param writer A writer that write messages from an input stream.
 | 
					 * @param writer A writer that write messages from an input stream.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public record MessageType<T extends Message>(
 | 
					public record MessageTypeSerializer<T extends Message>(
 | 
				
			||||||
		Class<T> messageClass,
 | 
							Class<T> messageClass,
 | 
				
			||||||
		Function<T, Integer> byteSizeFunction,
 | 
							Function<T, Integer> byteSizeFunction,
 | 
				
			||||||
		MessageReader<T> reader,
 | 
							MessageReader<T> reader,
 | 
				
			||||||
		MessageWriter<T> writer
 | 
							MessageWriter<T> writer
 | 
				
			||||||
) {
 | 
					) {
 | 
				
			||||||
	private static final Map<Class<?>, MessageType<?>> generatedMessageTypes = new HashMap<>();
 | 
						private static final Map<Class<?>, MessageTypeSerializer<?>> generatedMessageTypes = new HashMap<>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * Gets the {@link MessageType} instance for a given message class, and
 | 
						 * Gets the {@link MessageTypeSerializer} instance for a given message class, and
 | 
				
			||||||
	 * generates a new implementation if none exists yet.
 | 
						 * generates a new implementation if none exists yet.
 | 
				
			||||||
	 * @param messageClass The class of the message to get a type for.
 | 
						 * @param messageClass The class of the message to get a type for.
 | 
				
			||||||
	 * @param <T> The type of the message.
 | 
						 * @param <T> The type of the message.
 | 
				
			||||||
	 * @return The message type.
 | 
						 * @return The message type.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	@SuppressWarnings("unchecked")
 | 
						@SuppressWarnings("unchecked")
 | 
				
			||||||
	public static <T extends Message> MessageType<T> get(Class<T> messageClass) {
 | 
						public static <T extends Message> MessageTypeSerializer<T> get(Class<T> messageClass) {
 | 
				
			||||||
		return (MessageType<T>) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class<T>) c));
 | 
							return (MessageTypeSerializer<T>) generatedMessageTypes.computeIfAbsent(messageClass, c -> generateForRecord((Class<T>) c));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
| 
						 | 
					@ -49,7 +49,7 @@ public record MessageType<T extends Message>(
 | 
				
			||||||
	 * @param <T> The type of the message.
 | 
						 * @param <T> The type of the message.
 | 
				
			||||||
	 * @return A message type instance.
 | 
						 * @return A message type instance.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	public static <T extends Message> MessageType<T> generateForRecord(Class<T> messageTypeClass) {
 | 
						public static <T extends Message> MessageTypeSerializer<T> generateForRecord(Class<T> messageTypeClass) {
 | 
				
			||||||
		RecordComponent[] components = messageTypeClass.getRecordComponents();
 | 
							RecordComponent[] components = messageTypeClass.getRecordComponents();
 | 
				
			||||||
		Constructor<T> constructor;
 | 
							Constructor<T> constructor;
 | 
				
			||||||
		try {
 | 
							try {
 | 
				
			||||||
| 
						 | 
					@ -58,7 +58,7 @@ public record MessageType<T extends Message>(
 | 
				
			||||||
		} catch (NoSuchMethodException e) {
 | 
							} catch (NoSuchMethodException e) {
 | 
				
			||||||
			throw new IllegalArgumentException(e);
 | 
								throw new IllegalArgumentException(e);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		return new MessageType<>(
 | 
							return new MessageTypeSerializer<>(
 | 
				
			||||||
				messageTypeClass,
 | 
									messageTypeClass,
 | 
				
			||||||
				generateByteSizeFunction(components),
 | 
									generateByteSizeFunction(components),
 | 
				
			||||||
				generateReader(constructor),
 | 
									generateReader(constructor),
 | 
				
			||||||
| 
						 | 
					@ -35,10 +35,14 @@ public class MessageUtils {
 | 
				
			||||||
		return size;
 | 
							return size;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public static int getByteSize(Message msg) {
 | 
				
			||||||
 | 
							return 1 + (msg == null ? 0 : msg.byteSize());
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public static <T extends Message> int getByteSize(T[] items) {
 | 
						public static <T extends Message> int getByteSize(T[] items) {
 | 
				
			||||||
		int count = Integer.BYTES;
 | 
							int count = Integer.BYTES;
 | 
				
			||||||
		for (var item : items) {
 | 
							for (var item : items) {
 | 
				
			||||||
			count += item.byteSize();
 | 
								count += getByteSize(items);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		return count;
 | 
							return count;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -59,7 +63,7 @@ public class MessageUtils {
 | 
				
			||||||
		} else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) {
 | 
							} else if (o.getClass().isArray() && Message.class.isAssignableFrom(o.getClass().getComponentType())) {
 | 
				
			||||||
			return getByteSize((Message[]) o);
 | 
								return getByteSize((Message[]) o);
 | 
				
			||||||
		} else if (o instanceof Message) {
 | 
							} else if (o instanceof Message) {
 | 
				
			||||||
			return ((Message) o).byteSize();
 | 
								return getByteSize((Message) o);
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName());
 | 
								throw new IllegalArgumentException("Unsupported object type: " + o.getClass().getSimpleName());
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -3,15 +3,13 @@ package nl.andrewl.concord_core.msg;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.Error;
 | 
					import nl.andrewl.concord_core.msg.types.Error;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.ServerMetaData;
 | 
					import nl.andrewl.concord_core.msg.types.ServerMetaData;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.ServerUsers;
 | 
					import nl.andrewl.concord_core.msg.types.ServerUsers;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.UserData;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.channel.CreateThread;
 | 
					import nl.andrewl.concord_core.msg.types.channel.CreateThread;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
 | 
					import nl.andrewl.concord_core.msg.types.channel.MoveToChannel;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.chat.Chat;
 | 
					import nl.andrewl.concord_core.msg.types.chat.Chat;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
 | 
					import nl.andrewl.concord_core.msg.types.chat.ChatHistoryRequest;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
 | 
					import nl.andrewl.concord_core.msg.types.chat.ChatHistoryResponse;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.*;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.KeyData;
 | 
					 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.Registration;
 | 
					 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
 | 
					 | 
				
			||||||
import nl.andrewl.concord_core.util.ChainedDataOutputStream;
 | 
					import nl.andrewl.concord_core.util.ChainedDataOutputStream;
 | 
				
			||||||
import nl.andrewl.concord_core.util.ExtendedDataInputStream;
 | 
					import nl.andrewl.concord_core.util.ExtendedDataInputStream;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,6 +18,7 @@ import java.io.IOException;
 | 
				
			||||||
import java.io.InputStream;
 | 
					import java.io.InputStream;
 | 
				
			||||||
import java.io.OutputStream;
 | 
					import java.io.OutputStream;
 | 
				
			||||||
import java.util.HashMap;
 | 
					import java.util.HashMap;
 | 
				
			||||||
 | 
					import java.util.List;
 | 
				
			||||||
import java.util.Map;
 | 
					import java.util.Map;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
| 
						 | 
					@ -32,31 +31,36 @@ public class Serializer {
 | 
				
			||||||
	 * The mapping which defines each supported message type and the byte value
 | 
						 * The mapping which defines each supported message type and the byte value
 | 
				
			||||||
	 * used to identify it when reading and writing messages.
 | 
						 * used to identify it when reading and writing messages.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	private final Map<Byte, MessageType<?>> messageTypes = new HashMap<>();
 | 
						private final Map<Byte, MessageTypeSerializer<?>> messageTypes = new HashMap<>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * An inverse of {@link Serializer#messageTypes} which is used to look up a
 | 
						 * An inverse of {@link Serializer#messageTypes} which is used to look up a
 | 
				
			||||||
	 * message's byte value when you know the class of the message.
 | 
						 * message's byte value when you know the class of the message.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	private final Map<MessageType<?>, Byte> inverseMessageTypes = new HashMap<>();
 | 
						private final Map<MessageTypeSerializer<?>, Byte> inverseMessageTypes = new HashMap<>();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * Constructs a new serializer instance, with a standard set of supported
 | 
						 * Constructs a new serializer instance, with a standard set of supported
 | 
				
			||||||
	 * message types.
 | 
						 * message types.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	public Serializer() {
 | 
						public Serializer() {
 | 
				
			||||||
		registerType(0, Identification.class);
 | 
							List<Class<? extends Message>> messageClasses = List.of(
 | 
				
			||||||
		registerType(1, ServerWelcome.class);
 | 
									// Utility messages.
 | 
				
			||||||
		registerType(2, Chat.class);
 | 
									Error.class,
 | 
				
			||||||
		registerType(3, MoveToChannel.class);
 | 
									UserData.class,
 | 
				
			||||||
		registerType(4, ChatHistoryRequest.class);
 | 
									ServerUsers.class,
 | 
				
			||||||
		registerType(5, ChatHistoryResponse.class);
 | 
									// Client setup messages.
 | 
				
			||||||
		registerType(6, Registration.class);
 | 
									KeyData.class, ClientRegistration.class, ClientLogin.class, ClientSessionResume.class,
 | 
				
			||||||
		registerType(7, ServerUsers.class);
 | 
									RegistrationStatus.class, ServerWelcome.class, ServerMetaData.class,
 | 
				
			||||||
		registerType(8, ServerMetaData.class);
 | 
									// Chat messages.
 | 
				
			||||||
		registerType(9, Error.class);
 | 
									Chat.class, ChatHistoryRequest.class, ChatHistoryResponse.class,
 | 
				
			||||||
		registerType(10, CreateThread.class);
 | 
									// Channel messages.
 | 
				
			||||||
		registerType(11, KeyData.class);
 | 
									MoveToChannel.class,
 | 
				
			||||||
 | 
									CreateThread.class
 | 
				
			||||||
 | 
							);
 | 
				
			||||||
 | 
							for (int id = 0; id < messageClasses.size(); id++) {
 | 
				
			||||||
 | 
								registerType(id, messageClasses.get(id));
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
| 
						 | 
					@ -67,7 +71,7 @@ public class Serializer {
 | 
				
			||||||
	 * @param messageClass The type of message associated with the given id.
 | 
						 * @param messageClass The type of message associated with the given id.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	private synchronized <T extends Message> void registerType(int id, Class<T> messageClass) {
 | 
						private synchronized <T extends Message> void registerType(int id, Class<T> messageClass) {
 | 
				
			||||||
		MessageType<T> type = MessageType.get(messageClass);
 | 
							MessageTypeSerializer<T> type = MessageTypeSerializer.get(messageClass);
 | 
				
			||||||
		messageTypes.put((byte) id, type);
 | 
							messageTypes.put((byte) id, type);
 | 
				
			||||||
		inverseMessageTypes.put(type, (byte) id);
 | 
							inverseMessageTypes.put(type, (byte) id);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					@ -104,12 +108,12 @@ public class Serializer {
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	public <T extends Message> void writeMessage(Message msg, OutputStream o) throws IOException {
 | 
						public <T extends Message> void writeMessage(Message msg, OutputStream o) throws IOException {
 | 
				
			||||||
		DataOutputStream d = new DataOutputStream(o);
 | 
							DataOutputStream d = new DataOutputStream(o);
 | 
				
			||||||
		Byte typeId = inverseMessageTypes.get(msg.getType());
 | 
							Byte typeId = inverseMessageTypes.get(msg.getTypeSerializer());
 | 
				
			||||||
		if (typeId == null) {
 | 
							if (typeId == null) {
 | 
				
			||||||
			throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName());
 | 
								throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName());
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		d.writeByte(typeId);
 | 
							d.writeByte(typeId);
 | 
				
			||||||
		msg.getType().writer().write(msg, new ChainedDataOutputStream(d));
 | 
							msg.getTypeSerializer().writer().write(msg, new ChainedDataOutputStream(d));
 | 
				
			||||||
		d.flush();
 | 
							d.flush();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,7 @@
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Message components which are used by the server and the default client
 | 
				
			||||||
 | 
					 * implementation. Notably, the {@link nl.andrewl.concord_core.msg.Serializer}
 | 
				
			||||||
 | 
					 * within this package defines the set of supported message types, and provides
 | 
				
			||||||
 | 
					 * the highest-level interface to client-server communication.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg;
 | 
				
			||||||
| 
						 | 
					@ -5,11 +5,10 @@ import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * Error message which can be sent between either the server or client to
 | 
					 * Error message which can be sent between either the server or client to
 | 
				
			||||||
 * indicate an unsavory situation.
 | 
					 * indicate an unsavory situation.
 | 
				
			||||||
 | 
					 * @param level The severity level of the error.
 | 
				
			||||||
 | 
					 * @param message A message indicating what went wrong.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public record Error (
 | 
					public record Error (Level level, String message) implements Message {
 | 
				
			||||||
		Level level,
 | 
					 | 
				
			||||||
		String message
 | 
					 | 
				
			||||||
) implements Message {
 | 
					 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * The error level gives an indication as to the severity of the error.
 | 
						 * The error level gives an indication as to the severity of the error.
 | 
				
			||||||
	 * Warnings indicate that a user has attempted to do something which they
 | 
						 * Warnings indicate that a user has attempted to do something which they
 | 
				
			||||||
| 
						 | 
					@ -18,10 +17,20 @@ public record Error (
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	public enum Level {WARNING, ERROR}
 | 
						public enum Level {WARNING, ERROR}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Creates a warning message.
 | 
				
			||||||
 | 
						 * @param message The message text.
 | 
				
			||||||
 | 
						 * @return A warning-level error message.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	public static Error warning(String message) {
 | 
						public static Error warning(String message) {
 | 
				
			||||||
		return new Error(Level.WARNING, message);
 | 
							return new Error(Level.WARNING, message);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Creates an error message.
 | 
				
			||||||
 | 
						 * @param message The message text.
 | 
				
			||||||
 | 
						 * @return An error-level error message.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	public static Error error(String message) {
 | 
						public static Error error(String message) {
 | 
				
			||||||
		return new Error(Level.ERROR, message);
 | 
							return new Error(Level.ERROR, message);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,4 @@
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Messages pertaining to channel interaction and updates.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg.types.channel;
 | 
				
			||||||
| 
						 | 
					@ -8,9 +8,7 @@ import java.util.UUID;
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * This message contains information about a chat message that a user sent.
 | 
					 * This message contains information about a chat message that a user sent.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public record Chat (
 | 
					public record Chat (UUID id, UUID senderId, String senderNickname, long timestamp, String message) implements Message {
 | 
				
			||||||
		UUID id, UUID senderId, String senderNickname, long timestamp, String message
 | 
					 | 
				
			||||||
) implements Message {
 | 
					 | 
				
			||||||
	public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
 | 
						public Chat(UUID senderId, String senderNickname, long timestamp, String message) {
 | 
				
			||||||
		this(null, senderId, senderNickname, timestamp, message);
 | 
							this(null, senderId, senderNickname, timestamp, message);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -24,6 +24,7 @@ import java.util.stream.Collectors;
 | 
				
			||||||
 * </p>
 | 
					 * </p>
 | 
				
			||||||
 * <p>
 | 
					 * <p>
 | 
				
			||||||
 *     The following query parameters are supported:
 | 
					 *     The following query parameters are supported:
 | 
				
			||||||
 | 
					 * </p>
 | 
				
			||||||
 *     <ul>
 | 
					 *     <ul>
 | 
				
			||||||
 *         <li><code>count</code> - Fetch up to N messages. Minimum of 1, and
 | 
					 *         <li><code>count</code> - Fetch up to N messages. Minimum of 1, and
 | 
				
			||||||
 *         a server-specific maximum count, usually no higher than 1000.</li>
 | 
					 *         a server-specific maximum count, usually no higher than 1000.</li>
 | 
				
			||||||
| 
						 | 
					@ -37,7 +38,6 @@ import java.util.stream.Collectors;
 | 
				
			||||||
 *         is present, all others are ignored, and a list containing the single
 | 
					 *         is present, all others are ignored, and a list containing the single
 | 
				
			||||||
 *         message is returned, if it could be found, otherwise an empty list.</li>
 | 
					 *         message is returned, if it could be found, otherwise an empty list.</li>
 | 
				
			||||||
 *     </ul>
 | 
					 *     </ul>
 | 
				
			||||||
 * </p>
 | 
					 | 
				
			||||||
 * <p>
 | 
					 * <p>
 | 
				
			||||||
 *     Responses to this request are sent via {@link ChatHistoryResponse}, where
 | 
					 *     Responses to this request are sent via {@link ChatHistoryResponse}, where
 | 
				
			||||||
 *     the list of messages is always sorted by the timestamp.
 | 
					 *     the list of messages is always sorted by the timestamp.
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,5 +7,7 @@ import java.util.UUID;
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * The response that a server sends to a {@link ChatHistoryRequest}. The list of
 | 
					 * The response that a server sends to a {@link ChatHistoryRequest}. The list of
 | 
				
			||||||
 * messages is ordered by timestamp, with the newest messages appearing first.
 | 
					 * messages is ordered by timestamp, with the newest messages appearing first.
 | 
				
			||||||
 | 
					 * @param channelId The id of the channel that the chat messages belong to.
 | 
				
			||||||
 | 
					 * @param messages The list of messages that comprises the history.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public record ChatHistoryResponse (UUID channelId, Chat[] messages) implements Message {}
 | 
					public record ChatHistoryResponse (UUID channelId, Chat[] messages) implements Message {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,5 @@
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Messages pertaining to chat messages and other auxiliary messages regarding
 | 
				
			||||||
 | 
					 * the management of chat information.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg.types.chat;
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,9 @@
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg.types.client_setup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * This message is sent by clients to log into a server that they have already
 | 
				
			||||||
 | 
					 * registered with, but don't have a valid session token for.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public record ClientLogin(String username, String password) implements Message {}
 | 
				
			||||||
| 
						 | 
					@ -6,4 +6,9 @@ import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
 * The data that new users should send to a server in order to register in that
 | 
					 * The data that new users should send to a server in order to register in that
 | 
				
			||||||
 * server.
 | 
					 * server.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public record Registration (String username, String password) implements Message {}
 | 
					public record ClientRegistration(
 | 
				
			||||||
 | 
							String name,
 | 
				
			||||||
 | 
							String description,
 | 
				
			||||||
 | 
							String username,
 | 
				
			||||||
 | 
							String password
 | 
				
			||||||
 | 
					) implements Message {}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,9 @@
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg.types.client_setup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * This message is sent by the client to log into a server using a session token
 | 
				
			||||||
 | 
					 * instead of a username/password combination.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public record ClientSessionResume(String sessionToken) implements Message {}
 | 
				
			||||||
| 
						 | 
					@ -1,11 +0,0 @@
 | 
				
			||||||
package nl.andrewl.concord_core.msg.types.client_setup;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import nl.andrewl.concord_core.msg.Message;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
/**
 | 
					 | 
				
			||||||
 * 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.
 | 
					 | 
				
			||||||
 *
 | 
					 | 
				
			||||||
 * @param nickname
 | 
					 | 
				
			||||||
 */
 | 
					 | 
				
			||||||
public record Identification(String nickname, String sessionToken) implements Message {}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -5,5 +5,8 @@ import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
 * This message is sent as the first message from both the server and the client
 | 
					 * This message is sent as the first message from both the server and the client
 | 
				
			||||||
 * to establish an end-to-end encryption via a key exchange.
 | 
					 * to establish an end-to-end encryption via a key exchange.
 | 
				
			||||||
 | 
					 * @param iv The initialization vector bytes.
 | 
				
			||||||
 | 
					 * @param salt The salt bytes.
 | 
				
			||||||
 | 
					 * @param publicKey The public key.
 | 
				
			||||||
 */
 | 
					 */
 | 
				
			||||||
public record KeyData (byte[] iv, byte[] salt, byte[] publicKey) implements Message {}
 | 
					public record KeyData (byte[] iv, byte[] salt, byte[] publicKey) implements Message {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,15 @@
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg.types.client_setup;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * A response from the server which indicates the current status of the client's
 | 
				
			||||||
 | 
					 * registration request.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public record RegistrationStatus (Type type, String reason) implements Message {
 | 
				
			||||||
 | 
						public enum Type {PENDING, ACCEPTED, REJECTED}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public static RegistrationStatus pending() {
 | 
				
			||||||
 | 
							return new RegistrationStatus(Type.PENDING, null);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,4 @@
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Messages pertaining to the establishment of a connection with clients.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg.types.client_setup;
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,10 @@
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Contains all the various message types which can be sent between the server
 | 
				
			||||||
 | 
					 * and client.
 | 
				
			||||||
 | 
					 * <p>
 | 
				
			||||||
 | 
					 *     <em>Note that not all message types defined here may be supported by the
 | 
				
			||||||
 | 
					 *     latest version of Concord. See {@link nl.andrewl.concord_core.msg.Serializer}
 | 
				
			||||||
 | 
					 *     for the definitive list.</em>
 | 
				
			||||||
 | 
					 * </p>
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.msg.types;
 | 
				
			||||||
| 
						 | 
					@ -63,13 +63,16 @@ public class ChainedDataOutputStream {
 | 
				
			||||||
	public <T extends Message> ChainedDataOutputStream writeArray(T[] array) throws IOException {
 | 
						public <T extends Message> ChainedDataOutputStream writeArray(T[] array) throws IOException {
 | 
				
			||||||
		this.out.writeInt(array.length);
 | 
							this.out.writeInt(array.length);
 | 
				
			||||||
		for (var item : array) {
 | 
							for (var item : array) {
 | 
				
			||||||
			item.getType().writer().write(item, this);
 | 
								writeMessage(item);
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public <T extends Message> ChainedDataOutputStream writeMessage(Message msg) throws IOException {
 | 
						public <T extends Message> ChainedDataOutputStream writeMessage(Message msg) throws IOException {
 | 
				
			||||||
		msg.getType().writer().write(msg, this);
 | 
							this.out.writeBoolean(msg != null);
 | 
				
			||||||
 | 
							if (msg != null) {
 | 
				
			||||||
 | 
								msg.getTypeSerializer().writer().write(msg, this);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
		return this;
 | 
							return this;
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,7 @@
 | 
				
			||||||
package nl.andrewl.concord_core.util;
 | 
					package nl.andrewl.concord_core.util;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import nl.andrewl.concord_core.msg.Message;
 | 
					import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.MessageType;
 | 
					import nl.andrewl.concord_core.msg.MessageTypeSerializer;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import java.io.DataInputStream;
 | 
					import java.io.DataInputStream;
 | 
				
			||||||
import java.io.IOException;
 | 
					import java.io.IOException;
 | 
				
			||||||
| 
						 | 
					@ -45,7 +45,7 @@ public class ExtendedDataInputStream extends DataInputStream {
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	@SuppressWarnings("unchecked")
 | 
						@SuppressWarnings("unchecked")
 | 
				
			||||||
	public <T extends Message> T[] readArray(MessageType<T> type) throws IOException {
 | 
						public <T extends Message> T[] readArray(MessageTypeSerializer<T> type) throws IOException {
 | 
				
			||||||
		int length = super.readInt();
 | 
							int length = super.readInt();
 | 
				
			||||||
		T[] array = (T[]) Array.newInstance(type.messageClass(), length);
 | 
							T[] array = (T[]) Array.newInstance(type.messageClass(), length);
 | 
				
			||||||
		for (int i = 0; i < length; i++) {
 | 
							for (int i = 0; i < length; i++) {
 | 
				
			||||||
| 
						 | 
					@ -76,10 +76,10 @@ public class ExtendedDataInputStream extends DataInputStream {
 | 
				
			||||||
			int length = this.readInt();
 | 
								int length = this.readInt();
 | 
				
			||||||
			return this.readNBytes(length);
 | 
								return this.readNBytes(length);
 | 
				
			||||||
		} else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
 | 
							} else if (type.isArray() && Message.class.isAssignableFrom(type.getComponentType())) {
 | 
				
			||||||
			var messageType = MessageType.get((Class<? extends Message>) type.getComponentType());
 | 
								var messageType = MessageTypeSerializer.get((Class<? extends Message>) type.getComponentType());
 | 
				
			||||||
			return this.readArray(messageType);
 | 
								return this.readArray(messageType);
 | 
				
			||||||
		} else if (Message.class.isAssignableFrom(type)) {
 | 
							} else if (Message.class.isAssignableFrom(type)) {
 | 
				
			||||||
			var messageType = MessageType.get((Class<? extends Message>) type);
 | 
								var messageType = MessageTypeSerializer.get((Class<? extends Message>) type);
 | 
				
			||||||
			return messageType.reader().read(this);
 | 
								return messageType.reader().read(this);
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
			throw new IOException("Unsupported object type: " + type.getSimpleName());
 | 
								throw new IOException("Unsupported object type: " + type.getSimpleName());
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,3 +1,8 @@
 | 
				
			||||||
package nl.andrewl.concord_core.util;
 | 
					package nl.andrewl.concord_core.util;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Simple generic pair of two objects.
 | 
				
			||||||
 | 
					 * @param <A> The first object.
 | 
				
			||||||
 | 
					 * @param <B> The second object.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
public record Pair<A, B>(A first, B second) {}
 | 
					public record Pair<A, B>(A first, B second) {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,3 +1,9 @@
 | 
				
			||||||
package nl.andrewl.concord_core.util;
 | 
					package nl.andrewl.concord_core.util;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Simple generic triple of objects.
 | 
				
			||||||
 | 
					 * @param <A> The first object.
 | 
				
			||||||
 | 
					 * @param <B> The second object.
 | 
				
			||||||
 | 
					 * @param <C> The third object.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
public record Triple<A, B, C> (A first, B second, C third) {}
 | 
					public record Triple<A, B, C> (A first, B second, C third) {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,5 @@
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Contains some useful one-off utility classes that any consumer of Concord
 | 
				
			||||||
 | 
					 * messages could benefit from.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					package nl.andrewl.concord_core.util;
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,15 @@
 | 
				
			||||||
 | 
					<!DOCTYPE html>
 | 
				
			||||||
 | 
					<html lang="en">
 | 
				
			||||||
 | 
					<head>
 | 
				
			||||||
 | 
					    <meta charset="UTF-8">
 | 
				
			||||||
 | 
					    <title>Concord</title>
 | 
				
			||||||
 | 
					</head>
 | 
				
			||||||
 | 
					<body>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					<h1>Concord</h1>
 | 
				
			||||||
 | 
					<p>
 | 
				
			||||||
 | 
					    More content coming soon!
 | 
				
			||||||
 | 
					</p>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					</body>
 | 
				
			||||||
 | 
					</html>
 | 
				
			||||||
							
								
								
									
										19
									
								
								pom.xml
								
								
								
								
							
							
						
						
									
										19
									
								
								pom.xml
								
								
								
								
							| 
						 | 
					@ -16,9 +16,10 @@
 | 
				
			||||||
    </modules>
 | 
					    </modules>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    <properties>
 | 
					    <properties>
 | 
				
			||||||
        <maven.compiler.source>16</maven.compiler.source>
 | 
					        <maven.compiler.source>17</maven.compiler.source>
 | 
				
			||||||
        <maven.compiler.target>16</maven.compiler.target>
 | 
					        <maven.compiler.target>17</maven.compiler.target>
 | 
				
			||||||
        <java.version>16</java.version>
 | 
					        <maven.compiler.release>17</maven.compiler.release>
 | 
				
			||||||
 | 
					        <java.version>17</java.version>
 | 
				
			||||||
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 | 
					        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 | 
				
			||||||
    </properties>
 | 
					    </properties>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -47,6 +48,18 @@
 | 
				
			||||||
                    </annotationProcessorPaths>
 | 
					                    </annotationProcessorPaths>
 | 
				
			||||||
                </configuration>
 | 
					                </configuration>
 | 
				
			||||||
            </plugin>
 | 
					            </plugin>
 | 
				
			||||||
 | 
					            <plugin>
 | 
				
			||||||
 | 
					                <groupId>org.apache.maven.plugins</groupId>
 | 
				
			||||||
 | 
					                <artifactId>maven-javadoc-plugin</artifactId>
 | 
				
			||||||
 | 
					                <version>3.3.1</version>
 | 
				
			||||||
 | 
					                <configuration>
 | 
				
			||||||
 | 
					                    <detectLinks>false</detectLinks>
 | 
				
			||||||
 | 
					                    <detectOfflineLinks>false</detectOfflineLinks>
 | 
				
			||||||
 | 
					                    <failOnError>false</failOnError>
 | 
				
			||||||
 | 
					                    <failOnWarnings>false</failOnWarnings>
 | 
				
			||||||
 | 
					                    <show>private</show>
 | 
				
			||||||
 | 
					                </configuration>
 | 
				
			||||||
 | 
					            </plugin>
 | 
				
			||||||
        </plugins>
 | 
					        </plugins>
 | 
				
			||||||
    </build>
 | 
					    </build>
 | 
				
			||||||
</project>
 | 
					</project>
 | 
				
			||||||
| 
						 | 
					@ -42,6 +42,12 @@
 | 
				
			||||||
            <artifactId>jackson-annotations</artifactId>
 | 
					            <artifactId>jackson-annotations</artifactId>
 | 
				
			||||||
            <version>2.12.4</version>
 | 
					            <version>2.12.4</version>
 | 
				
			||||||
        </dependency>
 | 
					        </dependency>
 | 
				
			||||||
 | 
					        <!-- BCrypt implementation for password hashing. -->
 | 
				
			||||||
 | 
					        <dependency>
 | 
				
			||||||
 | 
					            <groupId>at.favre.lib</groupId>
 | 
				
			||||||
 | 
					            <artifactId>bcrypt</artifactId>
 | 
				
			||||||
 | 
					            <version>0.9.0</version>
 | 
				
			||||||
 | 
					        </dependency>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    </dependencies>
 | 
					    </dependencies>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@ module concord_server {
 | 
				
			||||||
	requires com.fasterxml.jackson.databind;
 | 
						requires com.fasterxml.jackson.databind;
 | 
				
			||||||
	requires com.fasterxml.jackson.core;
 | 
						requires com.fasterxml.jackson.core;
 | 
				
			||||||
	requires com.fasterxml.jackson.annotation;
 | 
						requires com.fasterxml.jackson.annotation;
 | 
				
			||||||
 | 
						requires bcrypt;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	requires java.base;
 | 
						requires java.base;
 | 
				
			||||||
	requires java.logging;
 | 
						requires java.logging;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -85,6 +85,8 @@ public class ConcordServer implements Runnable {
 | 
				
			||||||
	private final ClientManager clientManager;
 | 
						private final ClientManager clientManager;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private final DiscoveryServerPublisher discoveryServerPublisher;
 | 
						private final DiscoveryServerPublisher discoveryServerPublisher;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						@Getter
 | 
				
			||||||
	private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
 | 
						private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public ConcordServer() throws IOException {
 | 
						public ConcordServer() throws IOException {
 | 
				
			||||||
| 
						 | 
					@ -122,6 +124,9 @@ public class ConcordServer implements Runnable {
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * @return The server's metadata.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	public ServerMetaData getMetaData() {
 | 
						public ServerMetaData getMetaData() {
 | 
				
			||||||
		return new ServerMetaData(
 | 
							return new ServerMetaData(
 | 
				
			||||||
				this.config.getName(),
 | 
									this.config.getName(),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -33,7 +33,7 @@ public class Channel implements Comparable<Channel> {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * A document collection which holds all messages created in this channel,
 | 
						 * A document collection which holds all messages created in this channel,
 | 
				
			||||||
	 * indexed on id, timestamp, message, and sender's nickname.
 | 
						 * indexed on id, timestamp, message, and sender's username.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	private final NitriteCollection messageCollection;
 | 
						private final NitriteCollection messageCollection;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -9,7 +9,7 @@ import nl.andrewl.concord_server.cli.ServerCliCommand;
 | 
				
			||||||
public class ListClientsCommand implements ServerCliCommand {
 | 
					public class ListClientsCommand implements ServerCliCommand {
 | 
				
			||||||
	@Override
 | 
						@Override
 | 
				
			||||||
	public void handle(ConcordServer server, String[] args) throws Exception {
 | 
						public void handle(ConcordServer server, String[] args) throws Exception {
 | 
				
			||||||
		var users = server.getClientManager().getClients();
 | 
							var users = server.getClientManager().getConnectedClients();
 | 
				
			||||||
		if (users.isEmpty()) {
 | 
							if (users.isEmpty()) {
 | 
				
			||||||
			System.out.println("There are no connected clients.");
 | 
								System.out.println("There are no connected clients.");
 | 
				
			||||||
		} else {
 | 
							} else {
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,112 @@
 | 
				
			||||||
 | 
					package nl.andrewl.concord_server.client;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import at.favre.lib.crypto.bcrypt.BCrypt;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.ClientLogin;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.ClientRegistration;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.ClientSessionResume;
 | 
				
			||||||
 | 
					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.nio.charset.StandardCharsets;
 | 
				
			||||||
 | 
					import java.time.Instant;
 | 
				
			||||||
 | 
					import java.time.temporal.ChronoUnit;
 | 
				
			||||||
 | 
					import java.util.Map;
 | 
				
			||||||
 | 
					import java.util.UUID;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * This authentication service provides support for managing the client's
 | 
				
			||||||
 | 
					 * authentication status, such as registering new clients, generating tokens,
 | 
				
			||||||
 | 
					 * and logging in.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public class AuthenticationService {
 | 
				
			||||||
 | 
						private final NitriteCollection userCollection;
 | 
				
			||||||
 | 
						private final NitriteCollection sessionTokenCollection;
 | 
				
			||||||
 | 
						private final ConcordServer server;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public AuthenticationService(ConcordServer server, NitriteCollection userCollection) {
 | 
				
			||||||
 | 
							this.server = server;
 | 
				
			||||||
 | 
							this.userCollection = userCollection;
 | 
				
			||||||
 | 
							this.sessionTokenCollection = server.getDb().getCollection("session-tokens");
 | 
				
			||||||
 | 
							CollectionUtils.ensureIndexes(this.sessionTokenCollection, Map.of(
 | 
				
			||||||
 | 
									"sessionToken", IndexType.Unique,
 | 
				
			||||||
 | 
									"userId", IndexType.NonUnique,
 | 
				
			||||||
 | 
									"expiresAt", IndexType.NonUnique
 | 
				
			||||||
 | 
							));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public ClientConnectionData registerNewClient(ClientRegistration registration) {
 | 
				
			||||||
 | 
							UUID id = this.server.getIdProvider().newId();
 | 
				
			||||||
 | 
							String sessionToken = this.generateSessionToken(id);
 | 
				
			||||||
 | 
							String passwordHash = BCrypt.withDefaults().hashToString(12, registration.password().toCharArray());
 | 
				
			||||||
 | 
							Document doc = new Document(Map.of(
 | 
				
			||||||
 | 
									"id", id,
 | 
				
			||||||
 | 
									"username", registration.username(),
 | 
				
			||||||
 | 
									"passwordHash", passwordHash,
 | 
				
			||||||
 | 
									"name", registration.name(),
 | 
				
			||||||
 | 
									"description", registration.description(),
 | 
				
			||||||
 | 
									"createdAt", System.currentTimeMillis(),
 | 
				
			||||||
 | 
									"pending", false
 | 
				
			||||||
 | 
							));
 | 
				
			||||||
 | 
							this.userCollection.insert(doc);
 | 
				
			||||||
 | 
							return new ClientConnectionData(id, registration.username(), sessionToken, true);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public UUID registerPendingClient(ClientRegistration registration) {
 | 
				
			||||||
 | 
							UUID id = this.server.getIdProvider().newId();
 | 
				
			||||||
 | 
							String passwordHash = BCrypt.withDefaults().hashToString(12, registration.password().toCharArray());
 | 
				
			||||||
 | 
							Document doc = new Document(Map.of(
 | 
				
			||||||
 | 
									"id", id,
 | 
				
			||||||
 | 
									"username", registration.username(),
 | 
				
			||||||
 | 
									"passwordHash", passwordHash,
 | 
				
			||||||
 | 
									"name", registration.name(),
 | 
				
			||||||
 | 
									"description", registration.description(),
 | 
				
			||||||
 | 
									"createdAt", System.currentTimeMillis(),
 | 
				
			||||||
 | 
									"pending", true
 | 
				
			||||||
 | 
							));
 | 
				
			||||||
 | 
							this.userCollection.insert(doc);
 | 
				
			||||||
 | 
							return id;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public Document findAndAuthenticateUser(ClientLogin login) {
 | 
				
			||||||
 | 
							Document userDoc = this.userCollection.find(Filters.eq("username", login.username())).firstOrDefault();
 | 
				
			||||||
 | 
							if (userDoc != null) {
 | 
				
			||||||
 | 
								byte[] passwordHash = userDoc.get("passwordHash", String.class).getBytes(StandardCharsets.UTF_8);
 | 
				
			||||||
 | 
								if (BCrypt.verifyer().verify(login.password().getBytes(StandardCharsets.UTF_8), passwordHash).verified) {
 | 
				
			||||||
 | 
									return userDoc;
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
							return null;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public Document findAndAuthenticateUser(ClientSessionResume sessionResume) {
 | 
				
			||||||
 | 
							Document tokenDoc = this.sessionTokenCollection.find(Filters.and(
 | 
				
			||||||
 | 
									Filters.eq("sessionToken", sessionResume.sessionToken()),
 | 
				
			||||||
 | 
									Filters.gt("expiresAt", Instant.now().toEpochMilli())
 | 
				
			||||||
 | 
							)).firstOrDefault();
 | 
				
			||||||
 | 
							if (tokenDoc == null) return null;
 | 
				
			||||||
 | 
							UUID userId = tokenDoc.get("userId", UUID.class);
 | 
				
			||||||
 | 
							return this.userCollection.find(Filters.eq("id", userId)).firstOrDefault();
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public String generateSessionToken(UUID userId) {
 | 
				
			||||||
 | 
							String sessionToken = StringUtils.random(128);
 | 
				
			||||||
 | 
							long expiresAt = Instant.now().plus(7, ChronoUnit.DAYS).toEpochMilli();
 | 
				
			||||||
 | 
							Document doc = new Document(Map.of(
 | 
				
			||||||
 | 
									"sessionToken", sessionToken,
 | 
				
			||||||
 | 
									"userId", userId,
 | 
				
			||||||
 | 
									"expiresAt", expiresAt
 | 
				
			||||||
 | 
							));
 | 
				
			||||||
 | 
							this.sessionTokenCollection.insert(doc);
 | 
				
			||||||
 | 
							return sessionToken;
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						public void removeExpiredSessionTokens() {
 | 
				
			||||||
 | 
							long now = System.currentTimeMillis();
 | 
				
			||||||
 | 
							this.sessionTokenCollection.remove(Filters.lt("expiresAt", now));
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,15 @@
 | 
				
			||||||
 | 
					package nl.andrewl.concord_server.client;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import java.util.UUID;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					/**
 | 
				
			||||||
 | 
					 * Some common data that's used when dealing with a client who has just joined
 | 
				
			||||||
 | 
					 * the server.
 | 
				
			||||||
 | 
					 * @param id The user's unique id.
 | 
				
			||||||
 | 
					 * @param username The user's unique username.
 | 
				
			||||||
 | 
					 * @param sessionToken The user's new session token that can be used the next
 | 
				
			||||||
 | 
					 *                     time they want to log in.
 | 
				
			||||||
 | 
					 * @param newClient True if this client is connecting for the first time, or
 | 
				
			||||||
 | 
					 *                  false otherwise.
 | 
				
			||||||
 | 
					 */
 | 
				
			||||||
 | 
					public record ClientConnectionData(UUID id, String username, String sessionToken, boolean newClient) {}
 | 
				
			||||||
| 
						 | 
					@ -1,14 +1,11 @@
 | 
				
			||||||
package nl.andrewl.concord_server.client;
 | 
					package nl.andrewl.concord_server.client;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
import nl.andrewl.concord_core.msg.Message;
 | 
					import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.Error;
 | 
					 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.ServerUsers;
 | 
					import nl.andrewl.concord_core.msg.types.ServerUsers;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.UserData;
 | 
					import nl.andrewl.concord_core.msg.types.UserData;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.*;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.ServerWelcome;
 | 
					 | 
				
			||||||
import nl.andrewl.concord_server.ConcordServer;
 | 
					import nl.andrewl.concord_server.ConcordServer;
 | 
				
			||||||
import nl.andrewl.concord_server.util.CollectionUtils;
 | 
					import nl.andrewl.concord_server.util.CollectionUtils;
 | 
				
			||||||
import nl.andrewl.concord_server.util.StringUtils;
 | 
					 | 
				
			||||||
import org.dizitart.no2.Document;
 | 
					import org.dizitart.no2.Document;
 | 
				
			||||||
import org.dizitart.no2.IndexType;
 | 
					import org.dizitart.no2.IndexType;
 | 
				
			||||||
import org.dizitart.no2.NitriteCollection;
 | 
					import org.dizitart.no2.NitriteCollection;
 | 
				
			||||||
| 
						 | 
					@ -18,6 +15,7 @@ import java.io.ByteArrayOutputStream;
 | 
				
			||||||
import java.io.IOException;
 | 
					import java.io.IOException;
 | 
				
			||||||
import java.util.*;
 | 
					import java.util.*;
 | 
				
			||||||
import java.util.concurrent.ConcurrentHashMap;
 | 
					import java.util.concurrent.ConcurrentHashMap;
 | 
				
			||||||
 | 
					import java.util.concurrent.TimeUnit;
 | 
				
			||||||
import java.util.stream.Collectors;
 | 
					import java.util.stream.Collectors;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
/**
 | 
					/**
 | 
				
			||||||
| 
						 | 
					@ -27,58 +25,170 @@ import java.util.stream.Collectors;
 | 
				
			||||||
public class ClientManager {
 | 
					public class ClientManager {
 | 
				
			||||||
	private final ConcordServer server;
 | 
						private final ConcordServer server;
 | 
				
			||||||
	private final Map<UUID, ClientThread> clients;
 | 
						private final Map<UUID, ClientThread> clients;
 | 
				
			||||||
 | 
						private final Map<UUID, ClientThread> pendingClients;
 | 
				
			||||||
	private final NitriteCollection userCollection;
 | 
						private final NitriteCollection userCollection;
 | 
				
			||||||
 | 
						private final AuthenticationService authService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Constructs a new client manager for the given server.
 | 
				
			||||||
 | 
						 * @param server The server that the client manager is for.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	public ClientManager(ConcordServer server) {
 | 
						public ClientManager(ConcordServer server) {
 | 
				
			||||||
		this.server = server;
 | 
							this.server = server;
 | 
				
			||||||
		this.clients = new ConcurrentHashMap<>();
 | 
							this.clients = new ConcurrentHashMap<>();
 | 
				
			||||||
 | 
							this.pendingClients = new ConcurrentHashMap<>();
 | 
				
			||||||
		this.userCollection = server.getDb().getCollection("users");
 | 
							this.userCollection = server.getDb().getCollection("users");
 | 
				
			||||||
		CollectionUtils.ensureIndexes(this.userCollection, Map.of(
 | 
							CollectionUtils.ensureIndexes(this.userCollection, Map.of(
 | 
				
			||||||
				"id", IndexType.Unique,
 | 
									"id", IndexType.Unique,
 | 
				
			||||||
				"sessionToken", IndexType.Unique,
 | 
									"username", IndexType.Unique,
 | 
				
			||||||
				"nickname", IndexType.Fulltext
 | 
									"pending", IndexType.NonUnique
 | 
				
			||||||
		));
 | 
							));
 | 
				
			||||||
 | 
							this.authService = new AuthenticationService(server, this.userCollection);
 | 
				
			||||||
 | 
							// Start a daily scheduled removal of expired session tokens.
 | 
				
			||||||
 | 
							server.getScheduledExecutorService().scheduleAtFixedRate(this.authService::removeExpiredSessionTokens, 1, 1, TimeUnit.DAYS);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
	 * Registers a new client as connected to the server. This is done once the
 | 
						 * Handles an attempt by a new client to register as a user for this server.
 | 
				
			||||||
	 * client thread has received the correct identification information from
 | 
						 * If the server is set to automatically accept all new clients, the new
 | 
				
			||||||
	 * the client. The server will register the client in its global set of
 | 
						 * user is registered and the client is sent a {@link RegistrationStatus}
 | 
				
			||||||
	 * connected clients, and it will immediately move the client to the default
 | 
						 * with the {@link RegistrationStatus.Type#ACCEPTED} value, closely followed
 | 
				
			||||||
	 * channel.
 | 
						 * by a {@link ServerWelcome} message. Otherwise, the client is sent a
 | 
				
			||||||
	 * <p>
 | 
						 * {@link RegistrationStatus.Type#PENDING} response, which indicates that
 | 
				
			||||||
	 *     If the client provides a session token with their identification
 | 
						 * the client's registration is pending approval. The client can choose to
 | 
				
			||||||
	 *     message, then we should load their data from our database, otherwise
 | 
						 * remain connected and wait for approval, or disconnect and try logging in
 | 
				
			||||||
	 *     we assume this is a new client.
 | 
						 * later.
 | 
				
			||||||
	 * </p>
 | 
						 *
 | 
				
			||||||
	 * @param identification The client's identification data.
 | 
						 * @param registration The client's registration information.
 | 
				
			||||||
	 * @param clientThread The client manager thread.
 | 
						 * @param clientThread The client thread.
 | 
				
			||||||
 | 
						 * @throws InvalidIdentificationException If the user's registration info is
 | 
				
			||||||
 | 
						 * not valid.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	public void handleLogIn(Identification identification, ClientThread clientThread) {
 | 
						public void handleRegistration(ClientRegistration registration, ClientThread clientThread) throws InvalidIdentificationException {
 | 
				
			||||||
		ClientConnectionData data;
 | 
							Document userDoc = this.userCollection.find(Filters.eq("username", registration.username())).firstOrDefault();
 | 
				
			||||||
		try {
 | 
							if (userDoc != null) throw new InvalidIdentificationException("Username is taken.");
 | 
				
			||||||
			data = identification.sessionToken() == null ? getNewClientData(identification) : getClientDataFromDb(identification);
 | 
							if (this.server.getConfig().isAcceptAllNewClients()) {
 | 
				
			||||||
		} catch (InvalidIdentificationException e) {
 | 
								var clientData = this.authService.registerNewClient(registration);
 | 
				
			||||||
			clientThread.sendToClient(Error.warning(e.getMessage()));
 | 
								clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.ACCEPTED, null));
 | 
				
			||||||
			return;
 | 
								this.initializeClientConnection(clientData, clientThread);
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								var clientId = this.authService.registerPendingClient(registration);
 | 
				
			||||||
 | 
								this.initializePendingClientConnection(clientId, registration.username(), clientThread);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
		this.clients.put(data.id, clientThread);
 | 
						/**
 | 
				
			||||||
		clientThread.setClientId(data.id);
 | 
						 * Handles an attempt by a new client to login as an existing user to the
 | 
				
			||||||
		clientThread.setClientNickname(data.nickname);
 | 
						 * server. If the user's credentials are valid, then the following can
 | 
				
			||||||
 | 
						 * result:
 | 
				
			||||||
 | 
						 * <ul>
 | 
				
			||||||
 | 
						 *     <li>If the user's registration is still pending, they will be sent a
 | 
				
			||||||
 | 
						 *     {@link RegistrationStatus.Type#PENDING} response, to indicate that
 | 
				
			||||||
 | 
						 *     their registration is still pending approval.</li>
 | 
				
			||||||
 | 
						 *     <li>For non-pending (normal) users, they will be logged into the
 | 
				
			||||||
 | 
						 *     server and sent a {@link ServerWelcome} message.</li>
 | 
				
			||||||
 | 
						 * </ul>
 | 
				
			||||||
 | 
						 *
 | 
				
			||||||
 | 
						 * @param login The client's login credentials.
 | 
				
			||||||
 | 
						 * @param clientThread The client thread managing the connection.
 | 
				
			||||||
 | 
						 * @throws InvalidIdentificationException If the client's credentials are
 | 
				
			||||||
 | 
						 * incorrect.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						public void handleLogin(ClientLogin login, ClientThread clientThread) throws InvalidIdentificationException {
 | 
				
			||||||
 | 
							Document userDoc = this.authService.findAndAuthenticateUser(login);
 | 
				
			||||||
 | 
							if (userDoc == null) throw new InvalidIdentificationException("Username or password is incorrect.");
 | 
				
			||||||
 | 
							UUID userId = userDoc.get("id", UUID.class);
 | 
				
			||||||
 | 
							String username = userDoc.get("username", String.class);
 | 
				
			||||||
 | 
							boolean pending = userDoc.get("pending", Boolean.class);
 | 
				
			||||||
 | 
							if (pending) {
 | 
				
			||||||
 | 
								this.initializePendingClientConnection(userId, username, clientThread);
 | 
				
			||||||
 | 
							} else {
 | 
				
			||||||
 | 
								String sessionToken = this.authService.generateSessionToken(userId);
 | 
				
			||||||
 | 
								this.initializeClientConnection(new ClientConnectionData(userId, username, sessionToken, false), clientThread);
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Handles an attempt by a new client to login as an existing user to the
 | 
				
			||||||
 | 
						 * server with a session token from their previous session. If the token is
 | 
				
			||||||
 | 
						 * valid, the user will be logged in and sent a {@link ServerWelcome}
 | 
				
			||||||
 | 
						 * response.
 | 
				
			||||||
 | 
						 *
 | 
				
			||||||
 | 
						 * @param sessionResume The session token data.
 | 
				
			||||||
 | 
						 * @param clientThread The client thread managing the connection.
 | 
				
			||||||
 | 
						 * @throws InvalidIdentificationException If the token is invalid or refers
 | 
				
			||||||
 | 
						 * to a non-existent user.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						public void handleSessionResume(ClientSessionResume sessionResume, ClientThread clientThread) throws InvalidIdentificationException {
 | 
				
			||||||
 | 
							Document userDoc = this.authService.findAndAuthenticateUser(sessionResume);
 | 
				
			||||||
 | 
							if (userDoc == null) throw new InvalidIdentificationException("Invalid session. Log in to obtain a new session token.");
 | 
				
			||||||
 | 
							UUID userId = userDoc.get("id", UUID.class);
 | 
				
			||||||
 | 
							String username = userDoc.get("username", String.class);
 | 
				
			||||||
 | 
							String sessionToken = this.authService.generateSessionToken(userId);
 | 
				
			||||||
 | 
							this.initializeClientConnection(new ClientConnectionData(userId, username, sessionToken, false), clientThread);
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Used to accept or reject a pending user's registration. If the given user
 | 
				
			||||||
 | 
						 * is not pending approval, this method does nothing.
 | 
				
			||||||
 | 
						 * @param userId The id of the pending user.
 | 
				
			||||||
 | 
						 * @param accepted Whether to accept or reject.
 | 
				
			||||||
 | 
						 * @param reason The reason for rejection (or acceptance). This may be null.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						public void decidePendingUser(UUID userId, boolean accepted, String reason) {
 | 
				
			||||||
 | 
							Document userDoc = this.userCollection.find(Filters.and(Filters.eq("id", userId), Filters.eq("pending", true))).firstOrDefault();
 | 
				
			||||||
 | 
							if (userDoc != null) {
 | 
				
			||||||
 | 
								if (accepted) {
 | 
				
			||||||
 | 
									userDoc.put("pending", false);
 | 
				
			||||||
 | 
									this.userCollection.update(userDoc);
 | 
				
			||||||
 | 
									// If the pending user is still connected, upgrade them to a normal connected client.
 | 
				
			||||||
 | 
									var clientThread = this.pendingClients.remove(userId);
 | 
				
			||||||
 | 
									if (clientThread != null) {
 | 
				
			||||||
 | 
										clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.ACCEPTED, reason));
 | 
				
			||||||
 | 
										String username = userDoc.get("username", String.class);
 | 
				
			||||||
 | 
										String sessionToken = this.authService.generateSessionToken(userId);
 | 
				
			||||||
 | 
										this.initializeClientConnection(new ClientConnectionData(userId, username, sessionToken, true), clientThread);
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								} else {
 | 
				
			||||||
 | 
									this.userCollection.remove(userDoc);
 | 
				
			||||||
 | 
									var clientThread = this.pendingClients.remove(userId);
 | 
				
			||||||
 | 
									if (clientThread != null) {
 | 
				
			||||||
 | 
										clientThread.sendToClient(new RegistrationStatus(RegistrationStatus.Type.REJECTED, reason));
 | 
				
			||||||
 | 
									}
 | 
				
			||||||
 | 
								}
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Standard flow for initializing a connection to a client who has already
 | 
				
			||||||
 | 
						 * sent their identification message, and that has been checked to be valid.
 | 
				
			||||||
 | 
						 * @param clientData The data about the client that has connected.
 | 
				
			||||||
 | 
						 * @param clientThread The thread managing the client's connection.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						private void initializeClientConnection(ClientConnectionData clientData, ClientThread clientThread) {
 | 
				
			||||||
 | 
							clientThread.setClientId(clientData.id());
 | 
				
			||||||
 | 
							clientThread.setClientNickname(clientData.username());
 | 
				
			||||||
		var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow();
 | 
							var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow();
 | 
				
			||||||
		clientThread.sendToClient(new ServerWelcome(data.id, data.sessionToken, defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData()));
 | 
							clientThread.sendToClient(new ServerWelcome(clientData.id(), clientData.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.
 | 
							this.clients.put(clientData.id(), clientThread); // We only add the client after sending the welcome, to make sure that we send the welcome packet first.
 | 
				
			||||||
		defaultChannel.addClient(clientThread);
 | 
							defaultChannel.addClient(clientThread);
 | 
				
			||||||
		clientThread.setCurrentChannel(defaultChannel);
 | 
							clientThread.setCurrentChannel(defaultChannel);
 | 
				
			||||||
		System.out.printf(
 | 
							this.broadcast(new ServerUsers(this.getConnectedClients().toArray(new UserData[0])));
 | 
				
			||||||
				"Client %s(%s) joined%s, and was put into %s.\n",
 | 
						}
 | 
				
			||||||
				data.nickname,
 | 
					
 | 
				
			||||||
				data.id,
 | 
						/**
 | 
				
			||||||
				data.newClient ? " for the first time" : "",
 | 
						 * Initializes a connection to a client whose registration is pending, thus
 | 
				
			||||||
				defaultChannel
 | 
						 * they should simply keep their connection alive, and receive a {@link RegistrationStatus.Type#PENDING}
 | 
				
			||||||
		);
 | 
						 * message, instead of a {@link ServerWelcome}.
 | 
				
			||||||
		this.broadcast(new ServerUsers(this.getClients().toArray(new UserData[0])));
 | 
						 * @param clientId The id of the client.
 | 
				
			||||||
 | 
						 * @param pendingUsername The client's username.
 | 
				
			||||||
 | 
						 * @param clientThread The thread managing the client's connection.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						private void initializePendingClientConnection(UUID clientId, String pendingUsername, ClientThread clientThread) {
 | 
				
			||||||
 | 
							clientThread.setClientId(clientId);
 | 
				
			||||||
 | 
							clientThread.setClientNickname(pendingUsername);
 | 
				
			||||||
 | 
							clientThread.sendToClient(RegistrationStatus.pending());
 | 
				
			||||||
 | 
							this.pendingClients.put(clientId, clientThread);
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	/**
 | 
						/**
 | 
				
			||||||
| 
						 | 
					@ -87,12 +197,16 @@ public class ClientManager {
 | 
				
			||||||
	 * @param clientId The id of the client to remove.
 | 
						 * @param clientId The id of the client to remove.
 | 
				
			||||||
	 */
 | 
						 */
 | 
				
			||||||
	public void handleLogOut(UUID clientId) {
 | 
						public void handleLogOut(UUID clientId) {
 | 
				
			||||||
 | 
							var pendingClient = this.pendingClients.remove(clientId);
 | 
				
			||||||
 | 
							if (pendingClient != null) {
 | 
				
			||||||
 | 
								pendingClient.shutdown();
 | 
				
			||||||
 | 
							}
 | 
				
			||||||
		var client = this.clients.remove(clientId);
 | 
							var client = this.clients.remove(clientId);
 | 
				
			||||||
		if (client != null) {
 | 
							if (client != null) {
 | 
				
			||||||
			client.getCurrentChannel().removeClient(client);
 | 
								client.getCurrentChannel().removeClient(client);
 | 
				
			||||||
			client.shutdown();
 | 
								client.shutdown();
 | 
				
			||||||
			System.out.println("Client " + client + " has disconnected.");
 | 
								System.out.println("Client " + client + " has disconnected.");
 | 
				
			||||||
			this.broadcast(new ServerUsers(this.getClients().toArray(new UserData[0])));
 | 
								this.broadcast(new ServerUsers(this.getConnectedClients().toArray(new UserData[0])));
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -114,57 +228,48 @@ public class ClientManager {
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	public List<UserData> getClients() {
 | 
						/**
 | 
				
			||||||
 | 
						 * @return The list of connected clients.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						public List<UserData> getConnectedClients() {
 | 
				
			||||||
		return this.clients.values().stream()
 | 
							return this.clients.values().stream()
 | 
				
			||||||
				.sorted(Comparator.comparing(ClientThread::getClientNickname))
 | 
									.sorted(Comparator.comparing(ClientThread::getClientNickname))
 | 
				
			||||||
				.map(ClientThread::toData)
 | 
									.map(ClientThread::toData)
 | 
				
			||||||
				.collect(Collectors.toList());
 | 
									.collect(Collectors.toList());
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * @return The list of connected, pending clients.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
 | 
						public List<UserData> getPendingClients() {
 | 
				
			||||||
 | 
							return this.pendingClients.values().stream()
 | 
				
			||||||
 | 
									.sorted(Comparator.comparing(ClientThread::getClientNickname))
 | 
				
			||||||
 | 
									.map(ClientThread::toData)
 | 
				
			||||||
 | 
									.collect(Collectors.toList());
 | 
				
			||||||
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * @return The set of ids of all connected clients.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	public Set<UUID> getConnectedIds() {
 | 
						public Set<UUID> getConnectedIds() {
 | 
				
			||||||
		return this.clients.keySet();
 | 
							return this.clients.keySet();
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
						/**
 | 
				
			||||||
 | 
						 * Tries to find a connected client with the given id.
 | 
				
			||||||
 | 
						 * @param id The id to look for.
 | 
				
			||||||
 | 
						 * @return An optional client thread.
 | 
				
			||||||
 | 
						 */
 | 
				
			||||||
	public Optional<ClientThread> getClientById(UUID id) {
 | 
						public Optional<ClientThread> getClientById(UUID id) {
 | 
				
			||||||
		return Optional.ofNullable(this.clients.get(id));
 | 
							return Optional.ofNullable(this.clients.get(id));
 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
	private static record ClientConnectionData(UUID id, String nickname, String sessionToken, boolean newClient) {}
 | 
						/**
 | 
				
			||||||
 | 
						 * Tries to find a pending client with the given id.
 | 
				
			||||||
	private ClientConnectionData getClientDataFromDb(Identification identification) throws InvalidIdentificationException {
 | 
						 * @param id The id to look for.
 | 
				
			||||||
		var cursor = this.userCollection.find(Filters.eq("sessionToken", identification.sessionToken()));
 | 
						 * @return An optional client thread.
 | 
				
			||||||
		Document doc = cursor.firstOrDefault();
 | 
						 */
 | 
				
			||||||
		if (doc != null) {
 | 
						public Optional<ClientThread> getPendingClientById(UUID id) {
 | 
				
			||||||
			UUID id = doc.get("id", UUID.class);
 | 
							return Optional.ofNullable(this.pendingClients.get(id));
 | 
				
			||||||
			String nickname = identification.nickname();
 | 
					 | 
				
			||||||
			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.nickname();
 | 
					 | 
				
			||||||
		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);
 | 
					 | 
				
			||||||
	}
 | 
						}
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,8 +4,11 @@ import lombok.Getter;
 | 
				
			||||||
import lombok.Setter;
 | 
					import lombok.Setter;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.Encryption;
 | 
					import nl.andrewl.concord_core.msg.Encryption;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.Message;
 | 
					import nl.andrewl.concord_core.msg.Message;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.client_setup.Identification;
 | 
					import nl.andrewl.concord_core.msg.types.Error;
 | 
				
			||||||
import nl.andrewl.concord_core.msg.types.UserData;
 | 
					import nl.andrewl.concord_core.msg.types.UserData;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.ClientLogin;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.ClientRegistration;
 | 
				
			||||||
 | 
					import nl.andrewl.concord_core.msg.types.client_setup.ClientSessionResume;
 | 
				
			||||||
import nl.andrewl.concord_server.ConcordServer;
 | 
					import nl.andrewl.concord_server.ConcordServer;
 | 
				
			||||||
import nl.andrewl.concord_server.channel.Channel;
 | 
					import nl.andrewl.concord_server.channel.Channel;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -135,14 +138,25 @@ public class ClientThread extends Thread {
 | 
				
			||||||
			System.err.println("Could not establish end-to-end encryption with the client.");
 | 
								System.err.println("Could not establish end-to-end encryption with the client.");
 | 
				
			||||||
			return false;
 | 
								return false;
 | 
				
			||||||
		}
 | 
							}
 | 
				
			||||||
 | 
							final var clientManager = this.server.getClientManager();
 | 
				
			||||||
		int attempts = 0;
 | 
							int attempts = 0;
 | 
				
			||||||
		while (attempts < 5) {
 | 
							while (attempts < 5) {
 | 
				
			||||||
			try {
 | 
								try {
 | 
				
			||||||
				var msg = this.server.getSerializer().readMessage(this.in);
 | 
									var msg = this.server.getSerializer().readMessage(this.in);
 | 
				
			||||||
				if (msg instanceof Identification id) {
 | 
									if (msg instanceof ClientRegistration cr) {
 | 
				
			||||||
					this.server.getClientManager().handleLogIn(id, this);
 | 
										clientManager.handleRegistration(cr, this);
 | 
				
			||||||
					return true;
 | 
										return true;
 | 
				
			||||||
 | 
									} else if (msg instanceof ClientLogin cl) {
 | 
				
			||||||
 | 
										clientManager.handleLogin(cl, this);
 | 
				
			||||||
 | 
										return true;
 | 
				
			||||||
 | 
									} else if (msg instanceof ClientSessionResume csr) {
 | 
				
			||||||
 | 
										clientManager.handleSessionResume(csr, this);
 | 
				
			||||||
 | 
										return true;
 | 
				
			||||||
 | 
									} else {
 | 
				
			||||||
 | 
										this.sendToClient(Error.warning("Invalid identification message: " + msg.getClass().getSimpleName() + ", expected ClientRegistration, ClientLogin, or ClientSessionResume."));
 | 
				
			||||||
				}
 | 
									}
 | 
				
			||||||
 | 
								} catch (InvalidIdentificationException e) {
 | 
				
			||||||
 | 
									this.sendToClient(Error.warning(e.getMessage()));
 | 
				
			||||||
			} catch (IOException e) {
 | 
								} catch (IOException e) {
 | 
				
			||||||
				e.printStackTrace();
 | 
									e.printStackTrace();
 | 
				
			||||||
			}
 | 
								}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,6 +20,7 @@ public final class ServerConfig {
 | 
				
			||||||
	private String name;
 | 
						private String name;
 | 
				
			||||||
	private String description;
 | 
						private String description;
 | 
				
			||||||
	private int port;
 | 
						private int port;
 | 
				
			||||||
 | 
						private boolean acceptAllNewClients;
 | 
				
			||||||
	private int chatHistoryMaxCount;
 | 
						private int chatHistoryMaxCount;
 | 
				
			||||||
	private int chatHistoryDefaultCount;
 | 
						private int chatHistoryDefaultCount;
 | 
				
			||||||
	private int maxMessageLength;
 | 
						private int maxMessageLength;
 | 
				
			||||||
| 
						 | 
					@ -51,6 +52,7 @@ public final class ServerConfig {
 | 
				
			||||||
					"My Concord Server",
 | 
										"My Concord Server",
 | 
				
			||||||
					"A concord server for my friends and I.",
 | 
										"A concord server for my friends and I.",
 | 
				
			||||||
					8123,
 | 
										8123,
 | 
				
			||||||
 | 
										false,
 | 
				
			||||||
					100,
 | 
										100,
 | 
				
			||||||
					50,
 | 
										50,
 | 
				
			||||||
					8192,
 | 
										8192,
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
		Loading…
	
		Reference in New Issue