From 468758c7aba85e61cf90da7450de6657d07b2ea2 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Sat, 21 Aug 2021 21:49:01 +0200 Subject: [PATCH] Added initial prototype. --- .gitignore | 2 + README.md | 11 +- client/.gitignore | 1 + client/pom.xml | 28 +++++ client/src/main/java/module-info.java | 4 + .../concord_client/ClientMessageListener.java | 9 ++ .../andrewl/concord_client/ConcordClient.java | 109 ++++++++++++++++++ .../concord_client/gui/ChannelList.java | 6 + .../andrewl/concord_client/gui/ChatList.java | 31 +++++ .../andrewl/concord_client/gui/ChatPanel.java | 91 +++++++++++++++ .../concord_client/gui/ChatRenderer.java | 33 ++++++ .../concord_client/gui/MainWindow.java | 63 ++++++++++ .../andrewl/concord_client/gui/UserList.java | 6 + core/.gitignore | 1 + core/pom.xml | 13 +++ core/src/main/java/module-info.java | 6 + .../nl/andrewl/concord_core/msg/Message.java | 85 ++++++++++++++ .../andrewl/concord_core/msg/Serializer.java | 55 +++++++++ .../andrewl/concord_core/msg/types/Chat.java | 58 ++++++++++ .../msg/types/Identification.java | 38 ++++++ .../concord_core/msg/types/ServerWelcome.java | 32 +++++ .../nl/andrewl/concord_core/package-info.java | 5 + pom.xml | 51 ++++++++ server/.gitignore | 1 + server/pom.xml | 47 ++++++++ server/src/main/java/module-info.java | 9 ++ .../andrewl/concord_server/ClientThread.java | 99 ++++++++++++++++ .../andrewl/concord_server/ConcordServer.java | 75 ++++++++++++ .../nl/andrewl/concord_server/logback.xml | 12 ++ 29 files changed, 980 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 client/.gitignore create mode 100644 client/pom.xml create mode 100644 client/src/main/java/module-info.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/ClientMessageListener.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/ConcordClient.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java create mode 100644 client/src/main/java/nl/andrewl/concord_client/gui/UserList.java create mode 100644 core/.gitignore create mode 100644 core/pom.xml create mode 100644 core/src/main/java/module-info.java create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/Message.java create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java create mode 100644 core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java create mode 100644 core/src/main/java/nl/andrewl/concord_core/package-info.java create mode 100644 pom.xml create mode 100644 server/.gitignore create mode 100644 server/pom.xml create mode 100644 server/src/main/java/module-info.java create mode 100644 server/src/main/java/nl/andrewl/concord_server/ClientThread.java create mode 100644 server/src/main/java/nl/andrewl/concord_server/ConcordServer.java create mode 100644 server/src/main/resources/nl/andrewl/concord_server/logback.xml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a05e5d --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/.idea +/target \ No newline at end of file diff --git a/README.md b/README.md index 73736b8..8602e02 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,11 @@ # Concord -Console-based real-time messaging platform. +Console-based real-time messaging platform, inspired by Discord. + +This platform is organized by many independent servers, each of which supports the following: +- Multiple message channels. By default, there's one `general` channel. +- Broadcasting itself on certain discovery servers for users to find. The server decides where it wants to be discovered, if at all. +- Starting threads as spin-offs of a source message (with infinite recursion, i.e. threads within threads). +- Private message between users in a server. **No support for private messaging users outside the context of a server.** +- Banning users from the server. + +Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information. diff --git a/client/.gitignore b/client/.gitignore new file mode 100644 index 0000000..c41cc9e --- /dev/null +++ b/client/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/client/pom.xml b/client/pom.xml new file mode 100644 index 0000000..fda8858 --- /dev/null +++ b/client/pom.xml @@ -0,0 +1,28 @@ + + + + concord + nl.andrewl + 1.0-SNAPSHOT + + 4.0.0 + + concord-client + + + + nl.andrewl + concord-core + ${project.parent.version} + + + + com.googlecode.lanterna + lanterna + 3.1.1 + + + + \ No newline at end of file diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java new file mode 100644 index 0000000..53210a5 --- /dev/null +++ b/client/src/main/java/module-info.java @@ -0,0 +1,4 @@ +module concord_client { + requires concord_core; + requires com.googlecode.lanterna; +} \ No newline at end of file diff --git a/client/src/main/java/nl/andrewl/concord_client/ClientMessageListener.java b/client/src/main/java/nl/andrewl/concord_client/ClientMessageListener.java new file mode 100644 index 0000000..bac835a --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/ClientMessageListener.java @@ -0,0 +1,9 @@ +package nl.andrewl.concord_client; + +import nl.andrewl.concord_core.msg.Message; + +import java.io.IOException; + +public interface ClientMessageListener { + void messageReceived(ConcordClient client, Message message) throws IOException; +} diff --git a/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java new file mode 100644 index 0000000..5ab48a6 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/ConcordClient.java @@ -0,0 +1,109 @@ +package nl.andrewl.concord_client; + +import com.googlecode.lanterna.gui2.MultiWindowTextGUI; +import com.googlecode.lanterna.gui2.Window; +import com.googlecode.lanterna.gui2.WindowBasedTextGUI; +import com.googlecode.lanterna.screen.Screen; +import com.googlecode.lanterna.screen.TerminalScreen; +import com.googlecode.lanterna.terminal.DefaultTerminalFactory; +import com.googlecode.lanterna.terminal.Terminal; +import nl.andrewl.concord_client.gui.MainWindow; +import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.Serializer; +import nl.andrewl.concord_core.msg.types.Chat; +import nl.andrewl.concord_core.msg.types.Identification; +import nl.andrewl.concord_core.msg.types.ServerWelcome; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.Socket; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public class ConcordClient implements Runnable { + + private final Socket socket; + private final DataInputStream in; + private final DataOutputStream out; + private final long id; + private final String nickname; + private final Set messageListeners; + private volatile boolean running; + + public ConcordClient(String host, int port, String nickname) throws IOException { + this.socket = new Socket(host, port); + this.in = new DataInputStream(this.socket.getInputStream()); + this.out = new DataOutputStream(this.socket.getOutputStream()); + this.nickname = nickname; + Serializer.writeMessage(new Identification(nickname), this.out); + Message reply = Serializer.readMessage(this.in); + if (reply instanceof ServerWelcome welcome) { + this.id = welcome.getClientId(); + } else { + throw new IOException("Unexpected response from the server after sending identification message."); + } + this.messageListeners = new HashSet<>(); + } + + public void addListener(ClientMessageListener listener) { + this.messageListeners.add(listener); + } + + public void removeListener(ClientMessageListener listener) { + this.messageListeners.remove(listener); + } + + public void sendChat(String message) throws IOException { + Serializer.writeMessage(new Chat(this.id, this.nickname, System.currentTimeMillis(), message), this.out); + } + + public void shutdown() { + this.running = false; + if (!this.socket.isClosed()) { + try { + this.socket.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + @Override + public void run() { + this.running = true; + while (this.running) { + try { + Message msg = Serializer.readMessage(this.in); + for (var listener : this.messageListeners) { + listener.messageReceived(this, msg); + } + } catch (IOException e) { + e.printStackTrace(); + this.running = false; + } + } + try { + this.socket.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + + + public static void main(String[] args) throws IOException { + Terminal term = new DefaultTerminalFactory().createTerminal(); + Screen screen = new TerminalScreen(term); + WindowBasedTextGUI gui = new MultiWindowTextGUI(screen); + screen.startScreen(); + + Window window = new MainWindow(); + window.setHints(List.of(Window.Hint.FULL_SCREEN)); + gui.addWindow(window); + + window.waitUntilClosed(); + screen.stopScreen(); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java new file mode 100644 index 0000000..a207f10 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChannelList.java @@ -0,0 +1,6 @@ +package nl.andrewl.concord_client.gui; + +import com.googlecode.lanterna.gui2.AbstractListBox; + +public class ChannelList extends AbstractListBox { +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java new file mode 100644 index 0000000..a02831a --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChatList.java @@ -0,0 +1,31 @@ +package nl.andrewl.concord_client.gui; + +import com.googlecode.lanterna.gui2.AbstractListBox; +import nl.andrewl.concord_core.msg.types.Chat; + +public class ChatList extends AbstractListBox { + /** + * Adds one more item to the list box, at the end. + * + * @param item Item to add to the list box + * @return Itself + */ + @Override + public synchronized ChatList addItem(Chat item) { + super.addItem(item); + this.setSelectedIndex(this.getItemCount() - 1); + return this; + } + + /** + * Method that constructs the {@code ListItemRenderer} that this list box should use to draw the elements of the + * list box. This can be overridden to supply a custom renderer. Note that this is not the renderer used for the + * entire list box but for each item, called one by one. + * + * @return {@code ListItemRenderer} to use when drawing the items in the list + */ + @Override + protected ListItemRenderer createDefaultListItemRenderer() { + return new ChatRenderer(); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java new file mode 100644 index 0000000..52ecff0 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChatPanel.java @@ -0,0 +1,91 @@ +package nl.andrewl.concord_client.gui; + +import com.googlecode.lanterna.TerminalSize; +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.input.KeyStroke; +import com.googlecode.lanterna.input.KeyType; +import nl.andrewl.concord_client.ClientMessageListener; +import nl.andrewl.concord_client.ConcordClient; +import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.types.Chat; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * The main panel in which a user interacts with the application during normal + * operation. In here, the user is shown a list of the most recent chats in + * their current channel or thread, a text-box for sending a message, and some + * meta information in the sidebars which provides the user with a list of all + * threads and users in the server. + */ +public class ChatPanel extends Panel implements ClientMessageListener { + private final ChatList chatList; + private final TextBox inputTextBox; + private final ChannelList channelList; + private final UserList userList; + + private final ConcordClient client; + + public ChatPanel(ConcordClient client, Window window) { + super(new BorderLayout()); + this.client = client; + this.chatList = new ChatList(); + this.inputTextBox = new TextBox("", TextBox.Style.MULTI_LINE); + this.inputTextBox.setCaretWarp(true); + this.inputTextBox.setPreferredSize(new TerminalSize(0, 3)); + + this.channelList = new ChannelList(); + this.channelList.addItem("general"); + this.channelList.addItem("memes"); + this.channelList.addItem("testing"); + this.userList = new UserList(); + this.userList.addItem("andrew"); + this.userList.addItem("tester"); + + window.addWindowListener(new WindowListenerAdapter() { + @Override + public void onInput(Window basePane, KeyStroke keyStroke, AtomicBoolean deliverEvent) { + if (keyStroke.getKeyType() == KeyType.Enter) { + if (keyStroke.isShiftDown()) { + System.out.println("Adding newline"); + } else { + String text = inputTextBox.getText(); + if (text != null && !text.isBlank()) { + try { + System.out.println("Sending: " + text.trim()); + client.sendChat(text.trim()); + inputTextBox.setText(""); + } catch (IOException e) { + e.printStackTrace(); + } + } + deliverEvent.set(false); + } + } + } + }); + Border b; + b = Borders.doubleLine("Channels"); + b.setComponent(this.channelList); + this.addComponent(b, BorderLayout.Location.LEFT); + + b = Borders.doubleLine("Users"); + b.setComponent(this.userList); + this.addComponent(b, BorderLayout.Location.RIGHT); + + b = Borders.doubleLine("#general"); + b.setComponent(this.chatList); + this.addComponent(b, BorderLayout.Location.CENTER); + + this.addComponent(this.inputTextBox, BorderLayout.Location.BOTTOM); + this.inputTextBox.takeFocus(); + } + + @Override + public void messageReceived(ConcordClient client, Message message) throws IOException { + if (message instanceof Chat chat) { + this.chatList.addItem(chat); + } + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java b/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java new file mode 100644 index 0000000..36066b2 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java @@ -0,0 +1,33 @@ +package nl.andrewl.concord_client.gui; + +import com.googlecode.lanterna.TerminalTextUtils; +import com.googlecode.lanterna.graphics.ThemeDefinition; +import com.googlecode.lanterna.gui2.AbstractListBox; +import com.googlecode.lanterna.gui2.TextGUIGraphics; +import nl.andrewl.concord_core.msg.types.Chat; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; + +public class ChatRenderer extends AbstractListBox.ListItemRenderer { + @Override + public void drawItem(TextGUIGraphics graphics, ChatList listBox, int index, Chat chat, boolean selected, boolean focused) { + ThemeDefinition themeDefinition = listBox.getTheme().getDefinition(AbstractListBox.class); + if(selected && focused) { + graphics.applyThemeStyle(themeDefinition.getSelected()); + } + else { + graphics.applyThemeStyle(themeDefinition.getNormal()); + } + graphics.putString(0, 0, chat.getSenderNickname()); + Instant timestamp = Instant.ofEpochMilli(chat.getTimestamp()); + String timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm:ss")); + String label = chat.getSenderNickname() + " @ " + timeStr + " : " + chat.getMessage(); + label = TerminalTextUtils.fitString(label, graphics.getSize().getColumns()); + while(TerminalTextUtils.getColumnWidth(label) < graphics.getSize().getColumns()) { + label += " "; + } + graphics.putString(0, 0, label); + } +} diff --git a/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java new file mode 100644 index 0000000..67efc1d --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/gui/MainWindow.java @@ -0,0 +1,63 @@ +package nl.andrewl.concord_client.gui; + +import com.googlecode.lanterna.gui2.*; +import com.googlecode.lanterna.gui2.dialogs.TextInputDialogBuilder; +import com.googlecode.lanterna.input.KeyStroke; +import nl.andrewl.concord_client.ConcordClient; + +import java.io.IOException; +import java.util.concurrent.atomic.AtomicBoolean; + +public class MainWindow extends BasicWindow { + public MainWindow() { + super("Concord"); + Panel panel = new Panel(new LinearLayout(Direction.VERTICAL)); + + Button button = new Button("Connect to Server"); + button.addListener(b -> connectToServer()); + panel.addComponent(button); + + this.setComponent(panel); + this.addWindowListener(new WindowListenerAdapter() { + @Override + public void onInput(Window basePane, KeyStroke keyStroke, AtomicBoolean hasBeenHandled) { + if (keyStroke.getCharacter() != null && keyStroke.getCharacter() == 'c' && keyStroke.isCtrlDown()) { + System.exit(0); + } + } + }); + } + + public void connectToServer() { + System.out.println("Connecting to server!"); + var addressDialog = new TextInputDialogBuilder() + .setTitle("Server Address") + .setDescription("Enter the address of the server to connect to. For example, \"localhost:1234\".") + .setInitialContent("localhost:8123") + .build(); + String address = addressDialog.showDialog(this.getTextGUI()); + if (address == null) return; + String[] parts = address.split(":"); + if (parts.length != 2) return; + String host = parts[0]; + int port = Integer.parseInt(parts[1]); + + var nameDialog = new TextInputDialogBuilder() + .setTitle("Nickname") + .setDescription("Enter a nickname to use while in the server.") + .build(); + String nickname = nameDialog.showDialog(this.getTextGUI()); + if (nickname == null) return; + + try { + var client = new ConcordClient(host, port, nickname); + var chatPanel = new ChatPanel(client, this); + client.addListener(chatPanel); + new Thread(client).start(); + this.setComponent(chatPanel); + Borders.joinLinesWithFrame(this.getTextGUI().getScreen().newTextGraphics()); + } catch (IOException e) { + e.printStackTrace(); + } + } +} 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 new file mode 100644 index 0000000..a6c1979 --- /dev/null +++ b/client/src/main/java/nl/andrewl/concord_client/gui/UserList.java @@ -0,0 +1,6 @@ +package nl.andrewl.concord_client.gui; + +import com.googlecode.lanterna.gui2.AbstractListBox; + +public class UserList extends AbstractListBox { +} diff --git a/core/.gitignore b/core/.gitignore new file mode 100644 index 0000000..c41cc9e --- /dev/null +++ b/core/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/core/pom.xml b/core/pom.xml new file mode 100644 index 0000000..b797ee3 --- /dev/null +++ b/core/pom.xml @@ -0,0 +1,13 @@ + + + + concord + nl.andrewl + 1.0-SNAPSHOT + + 4.0.0 + + concord-core + \ No newline at end of file diff --git a/core/src/main/java/module-info.java b/core/src/main/java/module-info.java new file mode 100644 index 0000000..c79aa8c --- /dev/null +++ b/core/src/main/java/module-info.java @@ -0,0 +1,6 @@ +module concord_core { + requires static lombok; + + exports nl.andrewl.concord_core.msg to concord_server, concord_client; + exports nl.andrewl.concord_core.msg.types to concord_server, concord_client; +} \ No newline at end of file diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/Message.java b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java new file mode 100644 index 0000000..9bd98b8 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Message.java @@ -0,0 +1,85 @@ +package nl.andrewl.concord_core.msg; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +/** + * Represents any message which can be sent over the network. + *

+ * All messages consist of a single byte type identifier, followed by a + * payload whose structure depends on the message. + *

+ */ +public interface Message { + /** + * @return The exact number of bytes that this message will use when written + * to a stream. + */ + int getByteCount(); + + /** + * Writes this message to the given output stream. + * @param o The output stream to write to. + * @throws IOException If an error occurs while writing. + */ + void write(DataOutputStream o) throws IOException; + + /** + * Reads all of this message's properties from the given input stream. + *

+ * The single byte type identifier has already been read. + *

+ * @param i The input stream to read from. + * @throws IOException If an error occurs while reading. + */ + void read(DataInputStream i) throws IOException; + + // Utility methods. + + /** + * Gets the number of bytes that the given string will occupy when it is + * serialized. + * @param s The string. + * @return The number of bytes used to serialize the string. + */ + default int getByteSize(String s) { + return Integer.BYTES + s.getBytes(StandardCharsets.UTF_8).length; + } + + /** + * Writes a string to the given output stream using a length-prefixed format + * where an integer length precedes the string's bytes, which are encoded in + * UTF-8. + * @param s The string to write. + * @param o The output stream to write to. + * @throws IOException If the stream could not be written to. + */ + default void writeString(String s, DataOutputStream o) throws IOException { + if (s == null) { + o.writeInt(-1); + } else { + o.writeInt(s.length()); + o.write(s.getBytes(StandardCharsets.UTF_8)); + } + } + + /** + * Reads a string from the given input stream, using a length-prefixed + * format, where an integer length precedes the string's bytes, which are + * encoded in UTF-8. + * @param i The input stream to read from. + * @return The string which was read. + * @throws IOException If the stream could not be read, or if the string is + * malformed. + */ + default String readString(DataInputStream i) throws IOException { + int length = i.readInt(); + if (length == -1) return null; + byte[] data = new byte[length]; + int read = i.read(data); + if (read != length) throw new IOException("Not all bytes of a string of length " + length + " could be read."); + return new String(data, StandardCharsets.UTF_8); + } +} 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 new file mode 100644 index 0000000..4a45756 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/Serializer.java @@ -0,0 +1,55 @@ +package nl.andrewl.concord_core.msg; + +import nl.andrewl.concord_core.msg.types.Chat; +import nl.andrewl.concord_core.msg.types.Identification; +import nl.andrewl.concord_core.msg.types.ServerWelcome; + +import java.io.*; +import java.util.HashMap; +import java.util.Map; + +/** + * This class is responsible for reading and writing messages from streams. + */ +public class Serializer { + private static final Map> messageTypes = new HashMap<>(); + private static final Map, Byte> inverseMessageTypes = new HashMap<>(); + static { + registerType(0, Identification.class); + registerType(1, ServerWelcome.class); + registerType(2, Chat.class); + } + + private static void registerType(int id, Class clazz) { + messageTypes.put((byte) id, clazz); + inverseMessageTypes.put(clazz, (byte) id); + } + + public static Message readMessage(InputStream i) throws IOException { + DataInputStream d = new DataInputStream(i); + byte type = d.readByte(); + var clazz = messageTypes.get(type); + if (clazz == null) { + throw new IOException("Unsupported message type: " + type); + } + try { + var constructor = clazz.getConstructor(); + var message = constructor.newInstance(); + message.read(d); + return message; + } catch (Throwable e) { + throw new IOException("Could not instantiate new message object of type " + clazz.getSimpleName(), e); + } + } + + public static void writeMessage(Message msg, OutputStream o) throws IOException { + DataOutputStream d = new DataOutputStream(o); + Byte type = inverseMessageTypes.get(msg.getClass()); + if (type == null) { + throw new IOException("Unsupported message type: " + msg.getClass().getSimpleName()); + } + d.writeByte(type); + msg.write(d); + d.flush(); + } +} 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 new file mode 100644 index 0000000..a288255 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Chat.java @@ -0,0 +1,58 @@ +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; + +/** + * This message contains information about a chat message that a user sent. + */ +@Data +@NoArgsConstructor +public class Chat implements Message { + private long senderId; + private String senderNickname; + private long timestamp; + private String message; + + public Chat(long senderId, String senderNickname, long timestamp, String message) { + this.senderId = senderId; + this.senderNickname = senderNickname; + this.timestamp = timestamp; + this.message = message; + } + + public Chat(String message) { + this(-1, null, System.currentTimeMillis(), message); + } + + @Override + public int getByteCount() { + return 2 * Long.BYTES + getByteSize(this.message) + getByteSize(this.senderNickname); + } + + @Override + public void write(DataOutputStream o) throws IOException { + o.writeLong(this.senderId); + writeString(this.senderNickname, o); + o.writeLong(this.timestamp); + writeString(this.message, o); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.senderId = i.readLong(); + this.senderNickname = readString(i); + this.timestamp = i.readLong(); + this.message = readString(i); + } + + @Override + public String toString() { + return String.format("%s(%d): %s", this.senderNickname, this.senderId, this.message); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java new file mode 100644 index 0000000..7f3e267 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/Identification.java @@ -0,0 +1,38 @@ +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; + +/** + * This message is sent from the client to a server, to provide identification + * information about the client to the server when the connection is started. + */ +@Data +@NoArgsConstructor +public class Identification implements Message { + private String nickname; + + public Identification(String nickname) { + this.nickname = nickname; + } + + @Override + public int getByteCount() { + return getByteSize(this.nickname); + } + + @Override + public void write(DataOutputStream o) throws IOException { + writeString(this.nickname, o); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.nickname = readString(i); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java new file mode 100644 index 0000000..8fd6a90 --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/msg/types/ServerWelcome.java @@ -0,0 +1,32 @@ +package nl.andrewl.concord_core.msg.types; + +import lombok.Data; +import nl.andrewl.concord_core.msg.Message; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; + +/** + * This message is sent from the server to the client after the server accepts + * the client's identification and registers the client in the server. + */ +@Data +public class ServerWelcome implements Message { + private long clientId; + + @Override + public int getByteCount() { + return Long.BYTES; + } + + @Override + public void write(DataOutputStream o) throws IOException { + o.writeLong(this.clientId); + } + + @Override + public void read(DataInputStream i) throws IOException { + this.clientId = i.readLong(); + } +} diff --git a/core/src/main/java/nl/andrewl/concord_core/package-info.java b/core/src/main/java/nl/andrewl/concord_core/package-info.java new file mode 100644 index 0000000..e94402b --- /dev/null +++ b/core/src/main/java/nl/andrewl/concord_core/package-info.java @@ -0,0 +1,5 @@ +/** + * This package contains all the components needed by both the server and the + * client. + */ +package nl.andrewl.concord_core; \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..03c6cb1 --- /dev/null +++ b/pom.xml @@ -0,0 +1,51 @@ + + + 4.0.0 + + nl.andrewl + concord + pom + 1.0-SNAPSHOT + + server + core + client + + + + 16 + 16 + 16 + UTF-8 + + + + + org.projectlombok + lombok + 1.18.20 + provided + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + + org.projectlombok + lombok + 1.18.20 + + + + + + + \ No newline at end of file diff --git a/server/.gitignore b/server/.gitignore new file mode 100644 index 0000000..c41cc9e --- /dev/null +++ b/server/.gitignore @@ -0,0 +1 @@ +/target \ No newline at end of file diff --git a/server/pom.xml b/server/pom.xml new file mode 100644 index 0000000..80f9012 --- /dev/null +++ b/server/pom.xml @@ -0,0 +1,47 @@ + + + + concord + nl.andrewl + 1.0-SNAPSHOT + + 4.0.0 + + concord-server + + + + nl.andrewl + concord-core + ${project.parent.version} + + + + org.dizitart + nitrite + 3.4.3 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + + + org.projectlombok + lombok + 1.18.20 + + + + + + + \ No newline at end of file diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java new file mode 100644 index 0000000..71dd92c --- /dev/null +++ b/server/src/main/java/module-info.java @@ -0,0 +1,9 @@ +module concord_server { + requires nitrite; + requires static lombok; + + requires java.base; + requires java.logging; + + requires concord_core; +} \ No newline at end of file diff --git a/server/src/main/java/nl/andrewl/concord_server/ClientThread.java b/server/src/main/java/nl/andrewl/concord_server/ClientThread.java new file mode 100644 index 0000000..6fccd3b --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/ClientThread.java @@ -0,0 +1,99 @@ +package nl.andrewl.concord_server; + +import lombok.Getter; +import lombok.extern.java.Log; +import nl.andrewl.concord_core.msg.Message; +import nl.andrewl.concord_core.msg.Serializer; +import nl.andrewl.concord_core.msg.types.Chat; +import nl.andrewl.concord_core.msg.types.Identification; +import nl.andrewl.concord_core.msg.types.ServerWelcome; + +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.Socket; + +/** + * This thread is responsible for handling the connection to a single client of + * a server. + */ +@Log +public class ClientThread extends Thread { + private final Socket socket; + private final DataInputStream in; + private final DataOutputStream out; + + private final ConcordServer server; + + private Long clientId = null; + @Getter + private String clientNickname = null; + + public ClientThread(Socket socket, ConcordServer server) throws IOException { + this.socket = socket; + this.server = server; + this.in = new DataInputStream(socket.getInputStream()); + this.out = new DataOutputStream(socket.getOutputStream()); + } + + public void sendToClient(Message message) { + try { + Serializer.writeMessage(message, this.out); + } catch (IOException e) { + e.printStackTrace(); + } + } + + public void sendToClient(byte[] bytes) { + try { + this.out.write(bytes); + this.out.flush(); + } catch (IOException e) { + e.printStackTrace(); + } + } + + @Override + public void run() { + if (!identifyClient()) { + log.warning("Could not identify the client; aborting connection."); + return; + } + + while (true) { + try { + var msg = Serializer.readMessage(this.in); + if (msg instanceof Chat chat) { + this.server.handleChat(chat); + } + } catch (IOException e) { + log.info("Client disconnected: " + e.getMessage()); + if (this.clientId != null) { + this.server.deregisterClient(this.clientId); + } + break; + } + } + } + + private boolean identifyClient() { + int attempts = 0; + while (attempts < 5) { + try { + var msg = Serializer.readMessage(this.in); + if (msg instanceof Identification id) { + this.clientId = this.server.registerClient(this); + this.clientNickname = id.getNickname(); + var reply = new ServerWelcome(); + reply.setClientId(this.clientId); + Serializer.writeMessage(reply, this.out); + return true; + } + } catch (IOException e) { + e.printStackTrace(); + } + attempts++; + } + return false; + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java new file mode 100644 index 0000000..b60b329 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -0,0 +1,75 @@ +package nl.andrewl.concord_server; + +import lombok.extern.java.Log; +import nl.andrewl.concord_core.msg.Serializer; +import nl.andrewl.concord_core.msg.types.Chat; + +import java.io.ByteArrayOutputStream; +import java.io.DataOutputStream; +import java.io.IOException; +import java.net.ServerSocket; +import java.net.Socket; +import java.security.SecureRandom; +import java.util.Map; +import java.util.Random; +import java.util.concurrent.ConcurrentHashMap; + +@Log +public class ConcordServer implements Runnable { + private final Map clients = new ConcurrentHashMap<>(32); + private final int port; + private final Random random; + + public ConcordServer(int port) { + this.port = port; + this.random = new SecureRandom(); + } + + public long registerClient(ClientThread clientThread) { + long id = this.random.nextLong(); + log.info("Registering new client " + clientThread.getClientNickname() + " with id " + id); + this.clients.put(id, clientThread); + return id; + } + + public void deregisterClient(long clientId) { + this.clients.remove(clientId); + } + + public void handleChat(Chat chat) { + System.out.println(chat.getSenderNickname() + ": " + chat.getMessage()); + ByteArrayOutputStream baos = new ByteArrayOutputStream(chat.getByteCount()); + try { + Serializer.writeMessage(chat, new DataOutputStream(baos)); + } catch (IOException e) { + e.printStackTrace(); + return; + } + byte[] data = baos.toByteArray(); + for (var client : clients.values()) { + client.sendToClient(data); + } + } + + @Override + public void run() { + ServerSocket serverSocket; + try { + serverSocket = new ServerSocket(this.port); + log.info("Opened server on port " + this.port); + while (true) { + Socket socket = serverSocket.accept(); + log.info("Accepted new socket connection."); + ClientThread clientThread = new ClientThread(socket, this); + clientThread.start(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + public static void main(String[] args) { + var server = new ConcordServer(8123); + server.run(); + } +} diff --git a/server/src/main/resources/nl/andrewl/concord_server/logback.xml b/server/src/main/resources/nl/andrewl/concord_server/logback.xml new file mode 100644 index 0000000..57779e4 --- /dev/null +++ b/server/src/main/resources/nl/andrewl/concord_server/logback.xml @@ -0,0 +1,12 @@ + + + + + %d{dd.MM.yyyy HH:mm:ss} %boldCyan(%-34.-34thread) %red(%10.10X{jda.shard}) %boldGreen(%-15.-15logger{0}) %highlight(%-6level) %msg%n + + + + + + +