diff --git a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java index 2a2f3f9..2c52bc5 100644 --- a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java +++ b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java @@ -10,9 +10,9 @@ import com.googlecode.lanterna.terminal.Terminal; import lombok.Getter; import nl.andrewl.concord_client.event.EventManager; import nl.andrewl.concord_client.event.handlers.ChannelMovedHandler; -import nl.andrewl.concord_client.event.handlers.ChannelUsersResponseHandler; import nl.andrewl.concord_client.event.handlers.ChatHistoryResponseHandler; import nl.andrewl.concord_client.event.handlers.ServerMetaDataHandler; +import nl.andrewl.concord_client.event.handlers.ServerUsersHandler; import nl.andrewl.concord_client.gui.MainWindow; import nl.andrewl.concord_client.model.ClientModel; import nl.andrewl.concord_core.msg.Message; @@ -48,7 +48,7 @@ public class ConcordClient implements Runnable { // Add event listeners. this.eventManager.addHandler(MoveToChannel.class, new ChannelMovedHandler()); - this.eventManager.addHandler(ChannelUsersResponse.class, new ChannelUsersResponseHandler()); + this.eventManager.addHandler(ServerUsers.class, new ServerUsersHandler()); this.eventManager.addHandler(ChatHistoryResponse.class, new ChatHistoryResponseHandler()); this.eventManager.addHandler(Chat.class, (msg, client) -> client.getModel().getChatHistory().addChat(msg)); this.eventManager.addHandler(ServerMetaData.class, new ServerMetaDataHandler()); @@ -70,10 +70,9 @@ public class ConcordClient implements Runnable { this.serializer.writeMessage(new Identification(nickname), this.out); Message reply = this.serializer.readMessage(this.in); if (reply instanceof ServerWelcome welcome) { - var model = new ClientModel(welcome.getClientId(), nickname, welcome.getCurrentChannelId(), welcome.getMetaData()); + var model = new ClientModel(welcome.getClientId(), nickname, welcome.getCurrentChannelId(), welcome.getCurrentChannelName(), welcome.getMetaData()); // Start fetching initial data for the channel we were initially put into. - this.sendMessage(new ChannelUsersRequest(this.model.getCurrentChannelId())); - this.sendMessage(new ChatHistoryRequest(this.model.getCurrentChannelId(), "")); + this.sendMessage(new ChatHistoryRequest(model.getCurrentChannelId(), "")); return model; } else { throw new IOException("Unexpected response from the server after sending identification message."); diff --git a/client/src/main/java/nl/andrewl/concord_client/event/ClientModelListener.java b/client/src/main/java/nl/andrewl/concord_client/event/ClientModelListener.java index d74232e..00f6c98 100644 --- a/client/src/main/java/nl/andrewl/concord_client/event/ClientModelListener.java +++ b/client/src/main/java/nl/andrewl/concord_client/event/ClientModelListener.java @@ -7,7 +7,7 @@ import java.util.List; import java.util.UUID; public interface ClientModelListener { - default void channelMoved(UUID oldChannelId, UUID newChannelId) {} + default void channelMoved(UUID oldChannelId, String oldChannelName, UUID newChannelId, String newChannelName) {} default void usersUpdated(List users) {} diff --git a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelMovedHandler.java b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelMovedHandler.java index 5ca7ae0..75b4073 100644 --- a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelMovedHandler.java +++ b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChannelMovedHandler.java @@ -2,7 +2,6 @@ package nl.andrewl.concord_client.event.handlers; import nl.andrewl.concord_client.ConcordClient; import nl.andrewl.concord_client.event.MessageHandler; -import nl.andrewl.concord_core.msg.types.ChannelUsersRequest; import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.MoveToChannel; @@ -15,8 +14,7 @@ import nl.andrewl.concord_core.msg.types.MoveToChannel; public class ChannelMovedHandler implements MessageHandler { @Override public void handle(MoveToChannel msg, ConcordClient client) throws Exception { - client.getModel().setCurrentChannelId(msg.getChannelId()); - client.sendMessage(new ChatHistoryRequest(msg.getChannelId(), "")); - client.sendMessage(new ChannelUsersRequest(msg.getChannelId())); + client.getModel().setCurrentChannel(msg.getId(), msg.getChannelName()); + client.sendMessage(new ChatHistoryRequest(msg.getId(), "")); } } diff --git a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChatHistoryResponseHandler.java b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChatHistoryResponseHandler.java index 77866f5..e914801 100644 --- a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChatHistoryResponseHandler.java +++ b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ChatHistoryResponseHandler.java @@ -6,7 +6,7 @@ import nl.andrewl.concord_core.msg.types.ChatHistoryResponse; public class ChatHistoryResponseHandler implements MessageHandler { @Override - public void handle(ChatHistoryResponse msg, ConcordClient client) throws Exception { + public void handle(ChatHistoryResponse msg, ConcordClient client) { client.getModel().getChatHistory().setChats(msg.getMessages()); } } diff --git a/client/src/main/java/nl/andrewl/concord_client/event/handlers/ServerUsersHandler.java b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ServerUsersHandler.java new file mode 100644 index 0000000..3fd949b --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/event/handlers/ServerUsersHandler.java @@ -0,0 +1,12 @@ +package nl.andrewl.concord_client.event.handlers; + +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_client.event.MessageHandler; +import nl.andrewl.concord_core.msg.types.ServerUsers; + +public class ServerUsersHandler implements MessageHandler { + @Override + public void handle(ServerUsers msg, ConcordClient client) { + client.getModel().setKnownUsers(msg.getUsers()); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java index 1ad29ae..eb990f3 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelChatBox.java @@ -53,9 +53,7 @@ public class ChannelChatBox extends Panel { } public void refreshBorder() { - String name = client.getModel().getServerMetaData().getChannels().stream() - .filter(channelData -> channelData.getId().equals(client.getModel().getCurrentChannelId())) - .findAny().orElseThrow().getName(); + String name = client.getModel().getCurrentChannelName(); if (this.chatBorder != null) this.removeComponent(this.chatBorder); this.chatBorder = Borders.doubleLine("#" + name); this.chatBorder.setComponent(this.chatList); diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java b/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java index 911cc7b..8c9655f 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ServerPanel.java @@ -43,7 +43,7 @@ public class ServerPanel extends Panel implements ClientModelListener { } @Override - public void channelMoved(UUID oldChannelId, UUID newChannelId) { + public void channelMoved(UUID oldChannelId, String oldChannelName, UUID newChannelId, String newChannelName) { this.getTextGUI().getGUIThread().invokeLater(() -> { this.channelList.setChannels(); this.channelChatBox.getChatList().clearItems(); diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java b/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java index 93cc91a..0f67269 100644 --- a/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java +++ b/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java @@ -5,9 +5,10 @@ import com.googlecode.lanterna.gui2.Direction; import com.googlecode.lanterna.gui2.LinearLayout; import com.googlecode.lanterna.gui2.Panel; import nl.andrewl.concord_client.ConcordClient; -import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; +import nl.andrewl.concord_core.msg.types.MoveToChannel; import nl.andrewl.concord_core.msg.types.UserData; +import java.io.IOException; import java.util.List; public class UserList extends Panel { @@ -22,7 +23,14 @@ public class UserList extends Panel { this.removeAllComponents(); for (var user : usersResponse) { Button b = new Button(user.getName(), () -> { - System.out.println("Opening DM channel with user " + user.getName() + ", id: " + user.getId()); + if (!client.getModel().getId().equals(user.getId())) { + System.out.println("Opening DM channel with user " + user.getName() + ", id: " + user.getId()); + try { + client.sendMessage(new MoveToChannel(user.getId())); + } catch (IOException e) { + e.printStackTrace(); + } + } }); this.addComponent(b); } diff --git a/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java b/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java index 02a52b2..bbba707 100644 --- a/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java +++ b/client/src/main/java/nl/andrewl/concord_client/model/ClientModel.java @@ -17,25 +17,29 @@ public class ClientModel { private ServerMetaData serverMetaData; private UUID currentChannelId; + private String currentChannelName; private List knownUsers; private final ChatHistory chatHistory; private final List modelListeners; - public ClientModel(UUID id, String nickname, UUID currentChannelId, ServerMetaData serverMetaData) { + public ClientModel(UUID id, String nickname, UUID currentChannelId, String currentChannelName, ServerMetaData serverMetaData) { this.modelListeners = new CopyOnWriteArrayList<>(); this.id = id; this.nickname = nickname; this.currentChannelId = currentChannelId; + this.currentChannelName = currentChannelName; this.serverMetaData = serverMetaData; this.knownUsers = new ArrayList<>(); this.chatHistory = new ChatHistory(); } - public void setCurrentChannelId(UUID newChannelId) { + public void setCurrentChannel(UUID channelId, String channelName) { UUID oldId = this.currentChannelId; - this.currentChannelId = newChannelId; - this.modelListeners.forEach(listener -> listener.channelMoved(oldId, newChannelId)); + String oldName = this.currentChannelName; + this.currentChannelId = channelId; + this.currentChannelName = channelName; + this.modelListeners.forEach(listener -> listener.channelMoved(oldId, oldName, channelId, channelName)); } public void setKnownUsers(List users) { diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java index e1bca5e..db709fa 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/MessageUtils.java @@ -18,11 +18,11 @@ public class MessageUtils { /** * Gets the number of bytes that the given string will occupy when it is * serialized. - * @param s The string. + * @param s The string. This may be null. * @return The number of bytes used to serialize the string. */ public static int getByteSize(String s) { - return Integer.BYTES + s.getBytes(StandardCharsets.UTF_8).length; + return Integer.BYTES + (s == null ? 0 : s.getBytes(StandardCharsets.UTF_8).length); } /** @@ -61,22 +61,35 @@ public class MessageUtils { } public static void writeEnum(Enum value, DataOutputStream o) throws IOException { - o.writeInt(value.ordinal()); + if (value == null) { + o.writeInt(-1); + } else { + o.writeInt(value.ordinal()); + } } public static > T readEnum(Class e, DataInputStream i) throws IOException { int ordinal = i.readInt(); + if (ordinal == -1) return null; return e.getEnumConstants()[ordinal]; } public static void writeUUID(UUID value, DataOutputStream o) throws IOException { - o.writeLong(value.getMostSignificantBits()); - o.writeLong(value.getLeastSignificantBits()); + if (value == null) { + o.writeLong(-1); + o.writeLong(-1); + } else { + o.writeLong(value.getMostSignificantBits()); + o.writeLong(value.getLeastSignificantBits()); + } } public static UUID readUUID(DataInputStream i) throws IOException { long a = i.readLong(); long b = i.readLong(); + if (a == -1 && b == -1) { + return null; + } return new UUID(a, b); } @@ -95,15 +108,19 @@ public class MessageUtils { } } - public static List readList(Class type, DataInputStream i) throws IOException, ReflectiveOperationException { + public static List readList(Class type, DataInputStream i) throws IOException { int size = i.readInt(); - var constructor = type.getConstructor(); - List items = new ArrayList<>(size); - for (int k = 0; k < size; k++) { - var item = constructor.newInstance(); - item.read(i); - items.add(item); + try { + var constructor = type.getConstructor(); + List items = new ArrayList<>(size); + for (int k = 0; k < size; k++) { + var item = constructor.newInstance(); + item.read(i); + items.add(item); + } + return items; + } catch (ReflectiveOperationException e) { + throw new IOException(e); } - return items; } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java index 180e2c9..8b005fc 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java @@ -35,9 +35,10 @@ public class Serializer { registerType(4, ChatHistoryRequest.class); registerType(5, ChatHistoryResponse.class); registerType(6, ChannelUsersRequest.class); - registerType(7, ChannelUsersResponse.class); + registerType(7, ServerUsers.class); registerType(8, ServerMetaData.class); registerType(9, Error.class); + registerType(10, CreateThread.class); } /** diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersRequest.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersRequest.java index b33e598..2696441 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersRequest.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersRequest.java @@ -15,6 +15,7 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; @Data @NoArgsConstructor @AllArgsConstructor +@Deprecated public class ChannelUsersRequest implements Message { private UUID channelId; diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersResponse.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersResponse.java index 68ee08c..80ab9c9 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersResponse.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChannelUsersResponse.java @@ -17,10 +17,12 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; * the users in the channel that a client is in has changed. For example, when * a user leaves a channel, all others in that channel will be sent this message * to indicate that update. + * @deprecated Clients will be updated via a {@link ServerUsers} message. */ @Data @NoArgsConstructor @AllArgsConstructor +@Deprecated public class ChannelUsersResponse implements Message { private List users; @@ -36,10 +38,6 @@ public class ChannelUsersResponse implements Message { @Override public void read(DataInputStream i) throws IOException { - try { - this.users = readList(UserData.class, i); - } catch (ReflectiveOperationException e) { - throw new IOException(e); - } + this.users = readList(UserData.class, i); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java index 16e8253..1e5155b 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java @@ -1,5 +1,6 @@ package nl.andrewl.concord_core.msg.types; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import nl.andrewl.concord_core.msg.Message; @@ -7,6 +8,7 @@ import nl.andrewl.concord_core.msg.Message; import java.io.DataInputStream; import java.io.DataOutputStream; import java.io.IOException; +import java.util.Objects; import java.util.UUID; import static nl.andrewl.concord_core.msg.MessageUtils.*; @@ -16,13 +18,18 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; */ @Data @NoArgsConstructor +@AllArgsConstructor public class Chat implements Message { + private static final long ID_NONE = 0; + + private UUID id; private UUID senderId; private String senderNickname; private long timestamp; private String message; public Chat(UUID senderId, String senderNickname, long timestamp, String message) { + this.id = null; this.senderId = senderId; this.senderNickname = senderNickname; this.timestamp = timestamp; @@ -40,6 +47,7 @@ public class Chat implements Message { @Override public void write(DataOutputStream o) throws IOException { + writeUUID(this.id, o); writeUUID(this.senderId, o); writeString(this.senderNickname, o); o.writeLong(this.timestamp); @@ -48,6 +56,7 @@ public class Chat implements Message { @Override public void read(DataInputStream i) throws IOException { + this.id = readUUID(i); this.senderId = readUUID(i); this.senderNickname = readString(i); this.timestamp = i.readLong(); @@ -63,6 +72,7 @@ public class Chat implements Message { public boolean equals(Object o) { if (o.getClass().equals(this.getClass())) { Chat other = (Chat) o; + if (Objects.equals(this.getId(), other.getId())) return true; return this.getSenderId().equals(other.getSenderId()) && this.getTimestamp() == other.getTimestamp() && this.getSenderNickname().equals(other.getSenderNickname()) && diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java index a1cc69e..f6ffbfa 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ChatHistoryRequest.java @@ -41,6 +41,9 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; *
  • to - ISO-8601 timestamp indicating the timestamp * before which messages should be fetched. Only messages before this * point in time are returned.
  • + *
  • id - A single message id to fetch. If this parameter + * is present, all others are ignored, and a list containing the single + * message is returned, if it could be found, otherwise an empty list.
  • * *

    *

    diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/CreateThread.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/CreateThread.java new file mode 100644 index 0000000..e9b47e7 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/CreateThread.java @@ -0,0 +1,56 @@ +package nl.andrewl.concord_core.msg.types; + +import lombok.Data; +import lombok.NoArgsConstructor; +import nl.andrewl.concord_core.msg.Message; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.UUID; + +import static nl.andrewl.concord_core.msg.MessageUtils.*; + +/** + * This message is sent by clients when they indicate that they would like to + * create a new thread in their current channel. + *

    + * Conversely, this message is also sent by the server when a thread has + * been created by someone, and all clients need to be notified so that they + * can properly display to the user that a message has been turned into a + * thread. + *

    + */ +@Data +@NoArgsConstructor +public class CreateThread implements Message { + /** + * The id of the message from which the thread will be created. This will + * serve as the entry point of the thread, and the unique identifier for the + * thread. + */ + private UUID messageId; + + /** + * The title for the thread. This may be null, in which case the thread does + * not have any title. + */ + private String title; + + @Override + public int getByteCount() { + return UUID_BYTES + getByteSize(title); + } + + @Override + public void write(DataOutputStream o) throws IOException { + writeUUID(this.messageId, o); + writeString(this.title, o); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.messageId = readUUID(i); + this.title = readString(i); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/MoveToChannel.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/MoveToChannel.java index d173913..390eced 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/MoveToChannel.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/MoveToChannel.java @@ -1,5 +1,6 @@ package nl.andrewl.concord_core.msg.types; +import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; import nl.andrewl.concord_core.msg.Message; @@ -19,28 +20,48 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; * Conversely, a client can send this request to the server to indicate that * they would like to switch to the specified channel. *

    + *

    + * Clients can also send this message and provide the id of another client + * to request that they enter a private message channel with the referenced + * client. + *

    */ @Data +@AllArgsConstructor @NoArgsConstructor public class MoveToChannel implements Message { - private UUID channelId; + /** + * The id of the channel that the client is requesting or being moved to, or + * the id of another client that the user wishes to begin private messaging + * with. + */ + private UUID id; + + /** + * The name of the channel that the client is moved to. This is null in + * cases where the client is requesting to move to a channel, and is only + * provided by the server when it moves a client. + */ + private String channelName; public MoveToChannel(UUID channelId) { - this.channelId = channelId; + this.id = channelId; } @Override public int getByteCount() { - return UUID_BYTES; + return UUID_BYTES + getByteSize(this.channelName); } @Override public void write(DataOutputStream o) throws IOException { - writeUUID(this.channelId, o); + writeUUID(this.id, o); + writeString(this.channelName, o); } @Override public void read(DataInputStream i) throws IOException { - this.channelId = readUUID(i); + this.id = readUUID(i); + this.channelName = readString(i); } } diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerMetaData.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerMetaData.java index ea2612e..82182cc 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerMetaData.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerMetaData.java @@ -13,6 +13,11 @@ import java.util.UUID; import static nl.andrewl.concord_core.msg.MessageUtils.*; +/** + * Metadata is sent by the server to clients to inform them of the structure of + * the server. This includes basic information about the server's own properties + * as well as information about all top-level channels. + */ @Data @NoArgsConstructor @AllArgsConstructor @@ -34,13 +39,13 @@ public class ServerMetaData implements Message { @Override public void read(DataInputStream i) throws IOException { this.name = readString(i); - try { - this.channels = readList(ChannelData.class, i); - } catch (ReflectiveOperationException e) { - throw new IOException("Reflection exception", e); - } + this.channels = readList(ChannelData.class, i); } + /** + * Metadata about a top-level channel in the server which is visible and + * joinable for a user. + */ @Data @NoArgsConstructor @AllArgsConstructor diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerUsers.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerUsers.java new file mode 100644 index 0000000..ece8ac7 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerUsers.java @@ -0,0 +1,40 @@ +package nl.andrewl.concord_core.msg.types; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import nl.andrewl.concord_core.msg.Message; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.util.List; + +import static nl.andrewl.concord_core.msg.MessageUtils.*; + +/** + * This message is sent from the server to the client whenever a change happens + * which requires the server to notify clients about a change of the list of + * global users. + */ +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ServerUsers implements Message { + private List users; + + @Override + public int getByteCount() { + return getByteSize(this.users); + } + + @Override + public void write(DataOutputStream o) throws IOException { + writeList(this.users, o); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.users = readList(UserData.class, i); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java index 167de23..17864b6 100644 --- a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java @@ -22,17 +22,19 @@ import static nl.andrewl.concord_core.msg.MessageUtils.*; public class ServerWelcome implements Message { private UUID clientId; private UUID currentChannelId; + private String currentChannelName; private ServerMetaData metaData; @Override public int getByteCount() { - return 2 * UUID_BYTES + this.metaData.getByteCount(); + return 2 * UUID_BYTES + getByteSize(this.currentChannelName) + this.metaData.getByteCount(); } @Override public void write(DataOutputStream o) throws IOException { writeUUID(this.clientId, o); writeUUID(this.currentChannelId, o); + writeString(this.currentChannelName, o); this.metaData.write(o); } @@ -41,6 +43,7 @@ public class ServerWelcome implements Message { this.clientId = readUUID(i); this.currentChannelId = readUUID(i); this.metaData = new ServerMetaData(); + this.currentChannelName = readString(i); this.metaData.read(i); } } diff --git a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java deleted file mode 100644 index 8a566be..0000000 --- a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java +++ /dev/null @@ -1,74 +0,0 @@ -package nl.andrewl.concord_server; - -import nl.andrewl.concord_core.msg.types.MoveToChannel; - -import java.util.Map; -import java.util.Optional; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -public class ChannelManager { - private final ConcordServer server; - private final Map channelNameMap; - private final Map channelIdMap; - - public ChannelManager(ConcordServer server) { - this.server = server; - this.channelNameMap = new ConcurrentHashMap<>(); - this.channelIdMap = new ConcurrentHashMap<>(); - // Initialize the channels according to what's defined in the server's config. - for (var channelConfig : server.getConfig().getChannels()) { - this.addChannel(new Channel( - server, - UUID.fromString(channelConfig.getId()), - channelConfig.getName(), - server.getDb().getCollection("channel-" + channelConfig.getId()) - )); - } - } - - public Set getChannels() { - return Set.copyOf(this.channelIdMap.values()); - } - - public Optional getDefaultChannel() { - var optionalGeneral = this.getChannelByName("general"); - if (optionalGeneral.isPresent()) { - return optionalGeneral; - } - for (var channel : this.getChannels()) { - return Optional.of(channel); - } - return Optional.empty(); - } - - public void addChannel(Channel channel) { - this.channelNameMap.put(channel.getName(), channel); - this.channelIdMap.put(channel.getId(), channel); - } - - public void removeChannel(Channel channel) { - this.channelNameMap.remove(channel.getName()); - this.channelIdMap.remove(channel.getId()); - } - - public Optional getChannelByName(String name) { - return Optional.ofNullable(this.channelNameMap.get(name)); - } - - public Optional getChannelById(UUID id) { - return Optional.ofNullable(this.channelIdMap.get(id)); - } - - public void moveToChannel(ClientThread client, Channel channel) { - if (client.getCurrentChannel() != null) { - var previousChannel = client.getCurrentChannel(); - previousChannel.removeClient(client); - } - channel.addClient(client); - client.setCurrentChannel(channel); - client.sendToClient(new MoveToChannel(channel.getId())); - System.out.println("Moved client " + client + " to channel " + channel); - } -} diff --git a/server/src/main/java/nl/andrewl/concord_server/ChatThread.java b/server/src/main/java/nl/andrewl/concord_server/ChatThread.java deleted file mode 100644 index 7aeed1d..0000000 --- a/server/src/main/java/nl/andrewl/concord_server/ChatThread.java +++ /dev/null @@ -1,4 +0,0 @@ -package nl.andrewl.concord_server; - -public class ChatThread { -} diff --git a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java index 56db50e..4c16b60 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -1,26 +1,27 @@ package nl.andrewl.concord_server; import lombok.Getter; -import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Serializer; -import nl.andrewl.concord_core.msg.types.Identification; import nl.andrewl.concord_core.msg.types.ServerMetaData; -import nl.andrewl.concord_core.msg.types.ServerWelcome; -import nl.andrewl.concord_core.msg.types.UserData; +import nl.andrewl.concord_server.channel.ChannelManager; import nl.andrewl.concord_server.cli.ServerCli; +import nl.andrewl.concord_server.client.ClientManager; +import nl.andrewl.concord_server.client.ClientThread; import nl.andrewl.concord_server.config.ServerConfig; +import nl.andrewl.concord_server.event.EventManager; +import nl.andrewl.concord_server.util.IdProvider; +import nl.andrewl.concord_server.util.UUIDProvider; import org.dizitart.no2.Nitrite; -import java.io.ByteArrayOutputStream; import java.io.IOException; import java.net.ServerSocket; import java.net.Socket; import java.nio.file.Path; import java.util.Comparator; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.*; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; /** @@ -30,7 +31,6 @@ public class ConcordServer implements Runnable { private static final Path CONFIG_FILE = Path.of("server-config.json"); private static final Path DATABASE_FILE = Path.of("concord-server.db"); - private final Map clients; private volatile boolean running; private final ServerSocket serverSocket; @@ -79,6 +79,12 @@ public class ConcordServer implements Runnable { @Getter private final ChannelManager channelManager; + /** + * Manager that handles the collection of clients connected to this server. + */ + @Getter + private final ClientManager clientManager; + private final DiscoveryServerPublisher discoveryServerPublisher; private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); @@ -87,51 +93,13 @@ public class ConcordServer implements Runnable { this.config = ServerConfig.loadOrCreate(CONFIG_FILE, idProvider); this.discoveryServerPublisher = new DiscoveryServerPublisher(this.config); this.db = Nitrite.builder().filePath(DATABASE_FILE.toFile()).openOrCreate(); - this.clients = new ConcurrentHashMap<>(32); this.eventManager = new EventManager(this); this.channelManager = new ChannelManager(this); + this.clientManager = new ClientManager(this); this.serverSocket = new ServerSocket(this.config.getPort()); this.serializer = new Serializer(); } - /** - * Registers a new client as connected to the server. This is done once the - * client thread has received the correct identification information from - * the client. The server will register the client in its global set of - * connected clients, and it will immediately move the client to the default - * channel. - * @param identification The client's identification data. - * @param clientThread The client manager thread. - */ - public void registerClient(Identification identification, ClientThread clientThread) { - var id = this.idProvider.newId(); - System.out.printf("Client \"%s\" joined with id %s.\n", identification.getNickname(), id); - this.clients.put(id, clientThread); - clientThread.setClientId(id); - clientThread.setClientNickname(identification.getNickname()); - // Immediately add the client to the default channel and send the initial welcome message. - var defaultChannel = this.channelManager.getDefaultChannel().orElseThrow(); - clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), this.getMetaData())); - // It is important that we send the welcome message first. The client expects this as the initial response to their identification message. - defaultChannel.addClient(clientThread); - clientThread.setCurrentChannel(defaultChannel); - System.out.println("Moved client " + clientThread + " to " + defaultChannel); - } - - /** - * De-registers a client from the server, removing them from any channel - * they're currently in. - * @param clientId The id of the client to remove. - */ - public void deregisterClient(UUID clientId) { - var client = this.clients.remove(clientId); - if (client != null) { - client.getCurrentChannel().removeClient(client); - client.shutdown(); - System.out.println("Client " + client + " has disconnected."); - } - } - /** * @return True if the server is currently running, meaning it is accepting * connections, or false otherwise. @@ -155,13 +123,6 @@ public class ConcordServer implements Runnable { } } - public List getClients() { - return this.clients.values().stream() - .sorted(Comparator.comparing(ClientThread::getClientNickname)) - .map(ClientThread::toData) - .collect(Collectors.toList()); - } - public ServerMetaData getMetaData() { return new ServerMetaData( this.config.getName(), @@ -172,24 +133,6 @@ public class ConcordServer implements Runnable { ); } - /** - * Sends a message to every connected client, ignoring any channels. All - * clients connected to this server will receive this message. - * @param message The message to send. - */ - public void broadcast(Message message) { - ByteArrayOutputStream baos = new ByteArrayOutputStream(message.getByteCount()); - try { - this.serializer.writeMessage(message, baos); - byte[] data = baos.toByteArray(); - for (var client : this.clients.values()) { - client.sendToClient(data); - } - } catch (IOException e) { - e.printStackTrace(); - } - } - /** * Shuts down the server cleanly by doing the following things: *
      @@ -201,8 +144,8 @@ public class ConcordServer implements Runnable { */ private void shutdown() { System.out.println("Shutting down the server."); - for (var clientId : this.clients.keySet()) { - this.deregisterClient(clientId); + for (var clientId : this.clientManager.getConnectedIds()) { + this.clientManager.deregisterClient(clientId); } this.scheduledExecutorService.shutdown(); this.executorService.shutdown(); @@ -231,12 +174,16 @@ public class ConcordServer implements Runnable { ClientThread clientThread = new ClientThread(socket, this); clientThread.start(); } catch (IOException e) { - System.err.println("Could not accept new client connection: " + e.getMessage()); + if (!e.getMessage().equalsIgnoreCase("socket closed")) { + System.err.println("Could not accept new client connection: " + e.getMessage()); + } } } this.shutdown(); } + + public static void main(String[] args) throws IOException { var server = new ConcordServer(); new Thread(server).start(); diff --git a/server/src/main/java/nl/andrewl/concord_server/Channel.java b/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java similarity index 56% rename from server/src/main/java/nl/andrewl/concord_server/Channel.java rename to server/src/main/java/nl/andrewl/concord_server/channel/Channel.java index 7914f0a..5f90ec0 100644 --- a/server/src/main/java/nl/andrewl/concord_server/Channel.java +++ b/server/src/main/java/nl/andrewl/concord_server/channel/Channel.java @@ -1,11 +1,11 @@ -package nl.andrewl.concord_server; +package nl.andrewl.concord_server.channel; import lombok.Getter; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.Serializer; -import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; import nl.andrewl.concord_core.msg.types.UserData; -import org.dizitart.no2.IndexOptions; +import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.client.ClientThread; +import nl.andrewl.concord_server.util.CollectionUtils; import org.dizitart.no2.IndexType; import org.dizitart.no2.NitriteCollection; @@ -16,51 +16,36 @@ import java.util.concurrent.ConcurrentHashMap; /** * Represents a single communication area in which messages are sent by clients - * and received by all connected clients. + * and received by all connected clients. A channel is a top-level communication + * medium, and usually this is a server channel or private message between two + * clients in a server. */ @Getter public class Channel { private final ConcordServer server; - private UUID id; + private final UUID id; private String name; private final Set connectedClients; + /** + * A document collection which holds all messages created in this channel, + * indexed on id, timestamp, message, and sender's nickname. + */ private final NitriteCollection messageCollection; - public Channel(ConcordServer server, UUID id, String name, NitriteCollection messageCollection) { + public Channel(ConcordServer server, UUID id, String name) { this.server = server; this.id = id; this.name = name; this.connectedClients = ConcurrentHashMap.newKeySet(); - this.messageCollection = messageCollection; - this.initCollection(); - } - - /** - * Initializes this channel's nitrite database collection, which involves - * creating any indexes that don't yet exist. - */ - private void initCollection() { - if (!this.messageCollection.hasIndex("timestamp")) { - System.out.println("Adding index on \"timestamp\" field to collection " + this.messageCollection.getName()); - this.messageCollection.createIndex("timestamp", IndexOptions.indexOptions(IndexType.NonUnique)); - } - if (!this.messageCollection.hasIndex("senderNickname")) { - System.out.println("Adding index on \"senderNickname\" field to collection " + this.messageCollection.getName()); - this.messageCollection.createIndex("senderNickname", IndexOptions.indexOptions(IndexType.Fulltext)); - } - if (!this.messageCollection.hasIndex("message")) { - System.out.println("Adding index on \"message\" field to collection " + this.messageCollection.getName()); - this.messageCollection.createIndex("message", IndexOptions.indexOptions(IndexType.Fulltext)); - } - var fields = List.of("timestamp", "senderNickname", "message"); - for (var index : this.messageCollection.listIndices()) { - if (!fields.contains(index.getField())) { - System.out.println("Dropping unknown index " + index.getField() + " from collection " + index.getCollectionName()); - this.messageCollection.dropIndex(index.getField()); - } - } + this.messageCollection = server.getDb().getCollection("channel-" + id); + CollectionUtils.ensureIndexes(this.messageCollection, Map.of( + "timestamp", IndexType.NonUnique, + "senderNickname", IndexType.Fulltext, + "message", IndexType.Fulltext, + "id", IndexType.Unique + )); } /** @@ -70,11 +55,11 @@ public class Channel { */ public void addClient(ClientThread clientThread) { this.connectedClients.add(clientThread); - try { - this.sendMessage(new ChannelUsersResponse(this.getUserData())); - } catch (IOException e) { - e.printStackTrace(); - } +// try { +// this.sendMessage(new ChannelUsersResponse(this.getUserData())); +// } catch (IOException e) { +// e.printStackTrace(); +// } } /** @@ -84,11 +69,11 @@ public class Channel { */ public void removeClient(ClientThread clientThread) { this.connectedClients.remove(clientThread); - try { - this.sendMessage(new ChannelUsersResponse(this.getUserData())); - } catch (IOException e) { - e.printStackTrace(); - } +// try { +// this.sendMessage(new ChannelUsersResponse(this.getUserData())); +// } catch (IOException e) { +// e.printStackTrace(); +// } } /** @@ -125,12 +110,13 @@ public class Channel { public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof Channel channel)) return false; - return name.equals(channel.name); + if (Objects.equals(this.id, channel.getId())) return true; + return Objects.equals(this.name, channel.getName()); } @Override public int hashCode() { - return Objects.hash(name); + return Objects.hash(id, name); } @Override diff --git a/server/src/main/java/nl/andrewl/concord_server/channel/ChannelManager.java b/server/src/main/java/nl/andrewl/concord_server/channel/ChannelManager.java new file mode 100644 index 0000000..aa343e3 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/channel/ChannelManager.java @@ -0,0 +1,174 @@ +package nl.andrewl.concord_server.channel; + +import nl.andrewl.concord_core.msg.types.MoveToChannel; +import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.client.ClientThread; +import nl.andrewl.concord_server.util.CollectionUtils; +import org.dizitart.no2.Document; +import org.dizitart.no2.IndexType; +import org.dizitart.no2.NitriteCollection; +import org.dizitart.no2.filters.Filters; + +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * This manager is responsible for keeping track of all the channels in the + * server, and controlling modifications to them. + */ +public class ChannelManager { + private final ConcordServer server; + private final Map channelNameMap; + private final Map channelIdMap; + + private final Map, Channel> privateChannels; + private final NitriteCollection privateChannelCollection; + + public ChannelManager(ConcordServer server) { + this.server = server; + this.channelNameMap = new ConcurrentHashMap<>(); + this.channelIdMap = new ConcurrentHashMap<>(); + this.privateChannels = new ConcurrentHashMap<>(); + this.privateChannelCollection = this.server.getDb().getCollection("private-channels"); + CollectionUtils.ensureIndexes(this.privateChannelCollection, Map.of( + "idHash", IndexType.Unique, + "id", IndexType.Unique + )); + // Initialize the channels according to what's defined in the server's config. + for (var channelConfig : server.getConfig().getChannels()) { + this.addChannel(new Channel(server, UUID.fromString(channelConfig.getId()), channelConfig.getName())); + } + } + + public Set getChannels() { + return Set.copyOf(this.channelIdMap.values()); + } + + public Optional getDefaultChannel() { + var optionalDefault = this.getChannelByName(this.server.getConfig().getDefaultChannel()); + if (optionalDefault.isPresent()) { + return optionalDefault; + } + System.err.println("Could not find a channel with the name \"" + this.server.getConfig().getDefaultChannel() + "\"."); + for (var channel : this.getChannels()) { + return Optional.of(channel); + } + System.err.println("Could not find any channel to use as a default channel."); + return Optional.empty(); + } + + public void addChannel(Channel channel) { + this.channelNameMap.put(channel.getName(), channel); + this.channelIdMap.put(channel.getId(), channel); + } + + public void removeChannel(Channel channel) { + this.channelNameMap.remove(channel.getName()); + this.channelIdMap.remove(channel.getId()); + } + + public Optional getChannelByName(String name) { + return Optional.ofNullable(this.channelNameMap.get(name)); + } + + public Optional getChannelById(UUID id) { + return Optional.ofNullable(this.channelIdMap.get(id)); + } + + /** + * Moves a client to the given channel. This involves removing the client + * from whatever channel they're currently in, if any, moving them to the + * new channel, and sending them a message to indicate that it has been done. + * @param client The client to move. + * @param channel The channel to move the client to. + */ + public void moveToChannel(ClientThread client, Channel channel) { + if (client.getCurrentChannel() != null) { + var previousChannel = client.getCurrentChannel(); + previousChannel.removeClient(client); + } + channel.addClient(client); + client.setCurrentChannel(channel); + client.sendToClient(new MoveToChannel(channel.getId(), channel.getName())); + System.out.println("Moved client " + client + " to channel " + channel); + } + + /** + * Gets or creates a private channel for the given client ids to be able to + * communicate together. No other clients are allowed to access the channel. + * @param clientIds The id of each client which should have access to the + * channel. + * @return The private channel. + */ + public Channel getPrivateChannel(Set clientIds) { + if (clientIds.size() < 2) { + throw new IllegalArgumentException("At least 2 client ids are required for a private channel."); + } + return this.privateChannels.computeIfAbsent(clientIds, this::getPrivateChannelFromDatabase); + } + + /** + * Gets a private channel, given the id of a client who is part of the + * channel, and the id of the channel. + * @param clientId The id of the client that's requesting the channel. + * @param channelId The id of the private channel. + * @return The private channel. + */ + public Optional getPrivateChannel(UUID clientId, UUID channelId) { + Channel privateChannel = this.privateChannels.entrySet().stream() + .filter(entry -> entry.getKey().contains(clientId) && entry.getValue().getId().equals(channelId)) + .findAny().map(Map.Entry::getValue).orElse(null); + if (privateChannel == null) { + var cursor = this.privateChannelCollection.find(Filters.and(Filters.eq("id", channelId), Filters.in("clientIds", clientId))); + Document channelInfo = cursor.firstOrDefault(); + if (channelInfo != null) { + privateChannel = new Channel( + this.server, + channelInfo.get("id", UUID.class), + channelInfo.get("name", String.class) + ); + Set clientIds = Set.of(channelInfo.get("clientIds", UUID[].class)); + this.privateChannels.put(clientIds, privateChannel); + } + } + return Optional.ofNullable(privateChannel); + } + + /** + * Gets and instantiates a private channel from information stored in the + * "private-channels" collection of the database, or creates it if it does + * not exist yet. + * @param clientIds The set of client ids that the channel is for. + * @return The private channel. + */ + private Channel getPrivateChannelFromDatabase(Set clientIds) { + // First check if a private channel for these clients exists in the database. + String idHash = clientIds.stream().sorted().map(UUID::toString).collect(Collectors.joining()); + var cursor = this.privateChannelCollection.find(Filters.eq("idHash", idHash)); + Document channelInfo = cursor.firstOrDefault(); + if (channelInfo != null) { + // If it does exist, instantiate a channel with its info. + return new Channel( + this.server, + channelInfo.get("id", UUID.class), + channelInfo.get("name", String.class) + ); + } else { + // Otherwise, create the channel anew and save it in the collection. + var channel = new Channel(this.server, this.server.getIdProvider().newId(), "Private Channel"); + channelInfo = new Document(Map.of( + "idHash", idHash, + "id", channel.getId(), + "name", channel.getName(), + "clientIds", clientIds.toArray(new UUID[0]) + )); + this.privateChannelCollection.insert(channelInfo); + System.out.println("Created new private channel for clients: " + clientIds); + return channel; + } + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/cli/command/AddChannelCommand.java b/server/src/main/java/nl/andrewl/concord_server/cli/command/AddChannelCommand.java index b6ab208..6213121 100644 --- a/server/src/main/java/nl/andrewl/concord_server/cli/command/AddChannelCommand.java +++ b/server/src/main/java/nl/andrewl/concord_server/cli/command/AddChannelCommand.java @@ -1,12 +1,15 @@ package nl.andrewl.concord_server.cli.command; -import nl.andrewl.concord_server.Channel; import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.channel.Channel; import nl.andrewl.concord_server.cli.ServerCliCommand; import nl.andrewl.concord_server.config.ServerConfig; import java.util.UUID; +/** + * This command adds a new channel to the server. + */ public class AddChannelCommand implements ServerCliCommand { @Override public void handle(ConcordServer server, String[] args) throws Exception { @@ -32,8 +35,7 @@ public class AddChannelCommand implements ServerCliCommand { server.getConfig().getChannels().add(channelConfig); server.getConfig().save(); - var col = server.getDb().getCollection("channel-" + id); - server.getChannelManager().addChannel(new Channel(server, id, name, col)); - server.broadcast(server.getMetaData()); + server.getChannelManager().addChannel(new Channel(server, id, name)); + server.getClientManager().broadcast(server.getMetaData()); } } diff --git a/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java b/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java index 3501084..ca01b48 100644 --- a/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java +++ b/server/src/main/java/nl/andrewl/concord_server/cli/command/ListClientsCommand.java @@ -3,10 +3,13 @@ package nl.andrewl.concord_server.cli.command; import nl.andrewl.concord_server.ConcordServer; import nl.andrewl.concord_server.cli.ServerCliCommand; +/** + * This command shows a list of all clients that are currently connected to the server. + */ public class ListClientsCommand implements ServerCliCommand { @Override public void handle(ConcordServer server, String[] args) throws Exception { - var users = server.getClients(); + var users = server.getClientManager().getClients(); if (users.isEmpty()) { System.out.println("There are no connected clients."); } else { diff --git a/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java b/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java index 5ee7afd..df66e33 100644 --- a/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java +++ b/server/src/main/java/nl/andrewl/concord_server/cli/command/RemoveChannelCommand.java @@ -1,6 +1,6 @@ package nl.andrewl.concord_server.cli.command; -import nl.andrewl.concord_server.Channel; +import nl.andrewl.concord_server.channel.Channel; import nl.andrewl.concord_server.ConcordServer; import nl.andrewl.concord_server.cli.ServerCliCommand; @@ -38,7 +38,7 @@ public class RemoveChannelCommand implements ServerCliCommand { server.getDb().getContext().dropCollection(channelToRemove.getMessageCollection().getName()); server.getConfig().getChannels().removeIf(channelConfig -> channelConfig.getName().equals(channelToRemove.getName())); server.getConfig().save(); - server.broadcast(server.getMetaData()); + server.getClientManager().broadcast(server.getMetaData()); System.out.println("Removed the channel " + channelToRemove); } } diff --git a/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java new file mode 100644 index 0000000..44cbb28 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/client/ClientManager.java @@ -0,0 +1,101 @@ +package nl.andrewl.concord_server.client; + +import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.types.Identification; +import nl.andrewl.concord_core.msg.types.ServerUsers; +import nl.andrewl.concord_core.msg.types.ServerWelcome; +import nl.andrewl.concord_core.msg.types.UserData; +import nl.andrewl.concord_server.ConcordServer; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * The client manager is responsible for managing the set of clients connected + * to a server. + */ +public class ClientManager { + private final ConcordServer server; + private final Map clients; + + public ClientManager(ConcordServer server) { + this.server = server; + this.clients = new ConcurrentHashMap<>(); + } + + /** + * Registers a new client as connected to the server. This is done once the + * client thread has received the correct identification information from + * the client. The server will register the client in its global set of + * connected clients, and it will immediately move the client to the default + * channel. + * @param identification The client's identification data. + * @param clientThread The client manager thread. + */ + public void registerClient(Identification identification, ClientThread clientThread) { + var id = this.server.getIdProvider().newId(); + System.out.printf("Client \"%s\" joined with id %s.\n", identification.getNickname(), id); + this.clients.put(id, clientThread); + clientThread.setClientId(id); + clientThread.setClientNickname(identification.getNickname()); + // Immediately add the client to the default channel and send the initial welcome message. + var defaultChannel = this.server.getChannelManager().getDefaultChannel().orElseThrow(); + clientThread.sendToClient(new ServerWelcome(id, defaultChannel.getId(), defaultChannel.getName(), this.server.getMetaData())); + // It is important that we send the welcome message first. The client expects this as the initial response to their identification message. + defaultChannel.addClient(clientThread); + clientThread.setCurrentChannel(defaultChannel); + System.out.println("Moved client " + clientThread + " to " + defaultChannel); + this.broadcast(new ServerUsers(this.getClients())); + } + + /** + * De-registers a client from the server, removing them from any channel + * they're currently in. + * @param clientId The id of the client to remove. + */ + public void deregisterClient(UUID clientId) { + var client = this.clients.remove(clientId); + if (client != null) { + client.getCurrentChannel().removeClient(client); + client.shutdown(); + System.out.println("Client " + client + " has disconnected."); + this.broadcast(new ServerUsers(this.getClients())); + } + } + + /** + * Sends a message to every connected client, ignoring any channels. All + * clients connected to this server will receive this message. + * @param message The message to send. + */ + public void broadcast(Message message) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(message.getByteCount()); + try { + this.server.getSerializer().writeMessage(message, baos); + byte[] data = baos.toByteArray(); + for (var client : this.clients.values()) { + client.sendToClient(data); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + public List getClients() { + return this.clients.values().stream() + .sorted(Comparator.comparing(ClientThread::getClientNickname)) + .map(ClientThread::toData) + .collect(Collectors.toList()); + } + + public Set getConnectedIds() { + return this.clients.keySet(); + } + + public Optional getClientById(UUID id) { + return Optional.ofNullable(this.clients.get(id)); + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/ClientThread.java b/server/src/main/java/nl/andrewl/concord_server/client/ClientThread.java similarity index 93% rename from server/src/main/java/nl/andrewl/concord_server/ClientThread.java rename to server/src/main/java/nl/andrewl/concord_server/client/ClientThread.java index 7eba133..eadaf4c 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ClientThread.java +++ b/server/src/main/java/nl/andrewl/concord_server/client/ClientThread.java @@ -1,10 +1,12 @@ -package nl.andrewl.concord_server; +package nl.andrewl.concord_server.client; import lombok.Getter; import lombok.Setter; import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.types.Identification; import nl.andrewl.concord_core.msg.types.UserData; +import nl.andrewl.concord_server.channel.Channel; +import nl.andrewl.concord_server.ConcordServer; import java.io.DataInputStream; import java.io.DataOutputStream; @@ -109,7 +111,7 @@ public class ClientThread extends Thread { } if (this.clientId != null) { - this.server.deregisterClient(this.clientId); + this.server.getClientManager().deregisterClient(this.clientId); } try { if (!this.socket.isClosed()) { @@ -133,7 +135,7 @@ public class ClientThread extends Thread { try { var msg = this.server.getSerializer().readMessage(this.in); if (msg instanceof Identification id) { - this.server.registerClient(id, this); + this.server.getClientManager().registerClient(id, this); return true; } } catch (IOException e) { diff --git a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java index ea76465..684ee52 100644 --- a/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java +++ b/server/src/main/java/nl/andrewl/concord_server/config/ServerConfig.java @@ -5,7 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; -import nl.andrewl.concord_server.IdProvider; +import nl.andrewl.concord_server.util.IdProvider; import java.io.IOException; import java.io.UncheckedIOException; @@ -23,6 +23,7 @@ public final class ServerConfig { private int chatHistoryMaxCount; private int chatHistoryDefaultCount; private int maxMessageLength; + private String defaultChannel; private List channels; private List discoveryServers; @@ -53,6 +54,7 @@ public final class ServerConfig { 100, 50, 8192, + "general", List.of(new ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")), List.of(), filePath diff --git a/server/src/main/java/nl/andrewl/concord_server/event/ChannelMoveHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/ChannelMoveHandler.java index 6688985..cdab006 100644 --- a/server/src/main/java/nl/andrewl/concord_server/event/ChannelMoveHandler.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/ChannelMoveHandler.java @@ -1,16 +1,36 @@ package nl.andrewl.concord_server.event; +import nl.andrewl.concord_core.msg.types.Error; import nl.andrewl.concord_core.msg.types.MoveToChannel; -import nl.andrewl.concord_server.ClientThread; import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.client.ClientThread; + +import java.util.List; +import java.util.Set; /** - * Handles client requests to move to another channel. + * Handles client requests to move to another channel. We first check if the id + * which the client sent refers to a channel, in which case we move them to that + * channel. Otherwise, we look for a client with that id, and try to move the + * requester into a private channel with them. */ public class ChannelMoveHandler implements MessageHandler { @Override public void handle(MoveToChannel msg, ClientThread client, ConcordServer server) { - var optionalChannel = server.getChannelManager().getChannelById(msg.getChannelId()); - optionalChannel.ifPresent(channel -> server.getChannelManager().moveToChannel(client, channel)); + var optionalChannel = server.getChannelManager().getChannelById(msg.getId()); + if (optionalChannel.isPresent()) { + server.getChannelManager().moveToChannel(client, optionalChannel.get()); + } else { + var optionalClient = server.getClientManager().getClientById(msg.getId()); + if (optionalClient.isPresent()) { + var privateChannel = server.getChannelManager().getPrivateChannel(Set.of( + client.getClientId(), + optionalClient.get().getClientId() + )); + server.getChannelManager().moveToChannel(client, privateChannel); + } else { + client.sendToClient(Error.warning("Unknown channel or client id.")); + } + } } } diff --git a/server/src/main/java/nl/andrewl/concord_server/event/ChannelUsersRequestHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/ChannelUsersRequestHandler.java index df0d72b..e78835c 100644 --- a/server/src/main/java/nl/andrewl/concord_server/event/ChannelUsersRequestHandler.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/ChannelUsersRequestHandler.java @@ -2,7 +2,7 @@ package nl.andrewl.concord_server.event; import nl.andrewl.concord_core.msg.types.ChannelUsersRequest; import nl.andrewl.concord_core.msg.types.ChannelUsersResponse; -import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_server.client.ClientThread; import nl.andrewl.concord_server.ConcordServer; public class ChannelUsersRequestHandler implements MessageHandler { diff --git a/server/src/main/java/nl/andrewl/concord_server/event/ChatHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/ChatHandler.java index dd42219..e2c751e 100644 --- a/server/src/main/java/nl/andrewl/concord_server/event/ChatHandler.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/ChatHandler.java @@ -2,13 +2,18 @@ package nl.andrewl.concord_server.event; import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.Error; -import nl.andrewl.concord_server.ClientThread; import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.client.ClientThread; import org.dizitart.no2.Document; import java.io.IOException; import java.util.Map; +/** + * This handler is responsible for taking incoming chat messages and saving them + * to the channel's message collection, and then relaying the new message to all + * clients in the channel. + */ public class ChatHandler implements MessageHandler { @Override public void handle(Chat msg, ClientThread client, ConcordServer server) throws IOException { @@ -16,16 +21,22 @@ public class ChatHandler implements MessageHandler { client.getCurrentChannel().sendMessage(Error.warning("Message is too long.")); return; } - server.getExecutorService().submit(() -> { - var collection = client.getCurrentChannel().getMessageCollection(); - Document doc = new Document(Map.of( - "senderId", msg.getSenderId(), - "senderNickname", msg.getSenderNickname(), - "timestamp", msg.getTimestamp(), - "message", msg.getMessage() - )); - collection.insert(doc); - }); + /* + When we receive a message from the client, it will have a random UUID. + A compromised client could try and send a duplicate or otherwise + malicious UUID, so we overwrite it with a server-generated id which we + know is safe. + */ + msg.setId(server.getIdProvider().newId()); + var collection = client.getCurrentChannel().getMessageCollection(); + Document doc = new Document(Map.of( + "id", msg.getId(), + "senderId", msg.getSenderId(), + "senderNickname", msg.getSenderNickname(), + "timestamp", msg.getTimestamp(), + "message", msg.getMessage() + )); + collection.insert(doc); System.out.printf("#%s | %s: %s\n", client.getCurrentChannel(), client.getClientNickname(), msg.getMessage()); client.getCurrentChannel().sendMessage(msg); } diff --git a/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java index 34a4141..c98b8fb 100644 --- a/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/ChatHistoryRequestHandler.java @@ -3,9 +3,10 @@ package nl.andrewl.concord_server.event; import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.ChatHistoryResponse; -import nl.andrewl.concord_server.Channel; -import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_core.msg.types.Error; import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.channel.Channel; +import nl.andrewl.concord_server.client.ClientThread; import org.dizitart.no2.*; import org.dizitart.no2.filters.Filters; @@ -17,10 +18,21 @@ import java.util.*; public class ChatHistoryRequestHandler implements MessageHandler { @Override public void handle(ChatHistoryRequest msg, ClientThread client, ConcordServer server) { - var optionalChannel = server.getChannelManager().getChannelById(msg.getChannelId()); - if (optionalChannel.isPresent()) { - var channel = optionalChannel.get(); - var params = msg.getQueryAsMap(); + // First try and find a public channel with the given id. + var channel = server.getChannelManager().getChannelById(msg.getChannelId()).orElse(null); + if (channel == null) { + // Couldn't find a public channel, so look for a private channel this client is involved in. + channel = server.getChannelManager().getPrivateChannel(client.getClientId(), msg.getChannelId()).orElse(null); + } + // If we couldn't find a public or private channel, give up. + if (channel == null) { + client.sendToClient(Error.warning("Unknown channel id.")); + return; + } + var params = msg.getQueryAsMap(); + if (params.containsKey("id")) { + this.handleIdRequest(client, channel, params.get("id")); + } else { Long count = this.getOrDefault(params, "count", (long) server.getConfig().getChatHistoryDefaultCount()); if (count > server.getConfig().getChatHistoryMaxCount()) { return; @@ -31,14 +43,19 @@ public class ChatHistoryRequestHandler implements MessageHandler params, String key, Long defaultValue) { - String value = params.get(key); - if (value == null) return defaultValue; - try { - return Long.parseLong(value); - } catch (NumberFormatException e) { - return defaultValue; + /** + * Handles a request for a single message from a channel. + * @param client The client who's requesting the data. + * @param channel The channel in which to search for the message. + * @param id The id of the message. + */ + private void handleIdRequest(ClientThread client, Channel channel, String id) { + var cursor = channel.getMessageCollection().find(Filters.eq("id", id)); + List chats = new ArrayList<>(1); + for (var doc : cursor) { + chats.add(this.read(doc)); } + client.sendToClient(new ChatHistoryResponse(channel.getId(), chats)); } private ChatHistoryResponse getResponse(Channel channel, long count, Long from, Long to) { @@ -60,15 +77,43 @@ public class ChatHistoryRequestHandler implements MessageHandler chats = new ArrayList<>((int) count); for (Document doc : cursor) { - chats.add(new Chat( - doc.get("senderId", UUID.class), - doc.get("senderNickname", String.class), - doc.get("timestamp", Long.class), - doc.get("message", String.class) - )); + chats.add(this.read(doc)); } col.close(); chats.sort(Comparator.comparingLong(Chat::getTimestamp)); return new ChatHistoryResponse(channel.getId(), chats); } + + /** + * Helper method to read a {@link Chat} from a document retrieved from a + * collection. + * @param doc The document to read. + * @return The chat that was read. + */ + private Chat read(Document doc) { + return new Chat( + doc.get("id", UUID.class), + doc.get("senderId", UUID.class), + doc.get("senderNickname", String.class), + doc.get("timestamp", Long.class), + doc.get("message", String.class) + ); + } + + /** + * Helper method to get a long value or fall back to a default. + * @param params The parameters to check. + * @param key The key to get the value for. + * @param defaultValue The default value to return if no value exists. + * @return The value that was found, or the default value. + */ + private Long getOrDefault(Map params, String key, Long defaultValue) { + String value = params.get(key); + if (value == null) return defaultValue; + try { + return Long.parseLong(value); + } catch (NumberFormatException e) { + return defaultValue; + } + } } diff --git a/server/src/main/java/nl/andrewl/concord_server/event/EventListener.java b/server/src/main/java/nl/andrewl/concord_server/event/EventListener.java index b4385bc..3b9f2af 100644 --- a/server/src/main/java/nl/andrewl/concord_server/event/EventListener.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/EventListener.java @@ -1,7 +1,7 @@ package nl.andrewl.concord_server.event; import nl.andrewl.concord_core.msg.types.Chat; -import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_server.client.ClientThread; import nl.andrewl.concord_server.ConcordServer; public interface EventListener { diff --git a/server/src/main/java/nl/andrewl/concord_server/EventManager.java b/server/src/main/java/nl/andrewl/concord_server/event/EventManager.java similarity index 89% rename from server/src/main/java/nl/andrewl/concord_server/EventManager.java rename to server/src/main/java/nl/andrewl/concord_server/event/EventManager.java index 2389a4d..d26cc5d 100644 --- a/server/src/main/java/nl/andrewl/concord_server/EventManager.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/EventManager.java @@ -1,12 +1,12 @@ -package nl.andrewl.concord_server; +package nl.andrewl.concord_server.event; import lombok.extern.java.Log; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_core.msg.types.ChannelUsersRequest; import nl.andrewl.concord_core.msg.types.Chat; import nl.andrewl.concord_core.msg.types.ChatHistoryRequest; import nl.andrewl.concord_core.msg.types.MoveToChannel; -import nl.andrewl.concord_server.event.*; +import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.client.ClientThread; import java.util.HashMap; import java.util.Map; @@ -25,7 +25,6 @@ public class EventManager { this.messageHandlers = new HashMap<>(); this.messageHandlers.put(Chat.class, new ChatHandler()); this.messageHandlers.put(MoveToChannel.class, new ChannelMoveHandler()); - this.messageHandlers.put(ChannelUsersRequest.class, new ChannelUsersRequestHandler()); this.messageHandlers.put(ChatHistoryRequest.class, new ChatHistoryRequestHandler()); } diff --git a/server/src/main/java/nl/andrewl/concord_server/event/MessageHandler.java b/server/src/main/java/nl/andrewl/concord_server/event/MessageHandler.java index aa5df2b..bc540e4 100644 --- a/server/src/main/java/nl/andrewl/concord_server/event/MessageHandler.java +++ b/server/src/main/java/nl/andrewl/concord_server/event/MessageHandler.java @@ -1,7 +1,7 @@ package nl.andrewl.concord_server.event; import nl.andrewl.concord_core.msg.Message; -import nl.andrewl.concord_server.ClientThread; +import nl.andrewl.concord_server.client.ClientThread; import nl.andrewl.concord_server.ConcordServer; public interface MessageHandler { diff --git a/server/src/main/java/nl/andrewl/concord_server/util/CollectionUtils.java b/server/src/main/java/nl/andrewl/concord_server/util/CollectionUtils.java new file mode 100644 index 0000000..1d98b28 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/util/CollectionUtils.java @@ -0,0 +1,32 @@ +package nl.andrewl.concord_server.util; + +import org.dizitart.no2.IndexOptions; +import org.dizitart.no2.IndexType; +import org.dizitart.no2.NitriteCollection; + +import java.util.Map; + +public class CollectionUtils { + /** + * Ensures that the given nitrite collection has exactly the given set of + * indexes. It will remove any non-conforming indexes, and create new ones + * as necessary. + * @param collection The collection to operate on. + * @param indexMap A mapping containing keys referring to fields, and values + * that represent the type of index that should be on that + * field. + */ + public static void ensureIndexes(NitriteCollection collection, Map indexMap) { + for (var index : collection.listIndices()) { + var entry = indexMap.get(index.getField()); + if (entry == null || !index.getIndexType().equals(entry)) { + collection.dropIndex(index.getField()); + } + } + for (var entry : indexMap.entrySet()) { + if (!collection.hasIndex(entry.getKey())) { + collection.createIndex(entry.getKey(), IndexOptions.indexOptions(entry.getValue())); + } + } + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/IdProvider.java b/server/src/main/java/nl/andrewl/concord_server/util/IdProvider.java similarity index 64% rename from server/src/main/java/nl/andrewl/concord_server/IdProvider.java rename to server/src/main/java/nl/andrewl/concord_server/util/IdProvider.java index c3318c9..f276efa 100644 --- a/server/src/main/java/nl/andrewl/concord_server/IdProvider.java +++ b/server/src/main/java/nl/andrewl/concord_server/util/IdProvider.java @@ -1,4 +1,4 @@ -package nl.andrewl.concord_server; +package nl.andrewl.concord_server.util; import java.util.UUID; diff --git a/server/src/main/java/nl/andrewl/concord_server/UUIDProvider.java b/server/src/main/java/nl/andrewl/concord_server/util/UUIDProvider.java similarity index 78% rename from server/src/main/java/nl/andrewl/concord_server/UUIDProvider.java rename to server/src/main/java/nl/andrewl/concord_server/util/UUIDProvider.java index bea51ca..c8af17e 100644 --- a/server/src/main/java/nl/andrewl/concord_server/UUIDProvider.java +++ b/server/src/main/java/nl/andrewl/concord_server/util/UUIDProvider.java @@ -1,4 +1,4 @@ -package nl.andrewl.concord_server; +package nl.andrewl.concord_server.util; import java.util.UUID;