diff --git a/README.md b/README.md index 8602e02..e3dcb9e 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,45 @@ # Concord Console-based real-time messaging platform, inspired by Discord. -This platform is organized by many independent servers, each of which supports the following: +## Vision + +This platform will be organized by many independent servers, each of which will support 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. +# Concord Client + +To use the client, simply download the latest `concord-client.jar` JAR file from [the releases page](https://github.com/andrewlalis/Concord/releases) and run it with Java (version 16 or higher) from anywhere on your machine. + +Once you've started it, press **Enter** to click the button "Connect to Server". You will be prompted for the server IP address, and then a nickname for yourself, before you join the server. To disconnect, press **CTRL+C** at any time. + +# Concord Server + +To start up your own server, download the latest `concord-server.jar` JAR file from [the releases page](https://github.com/andrewlalis/Concord/releases) and run it with Java (version 16 or higher). The first time you run the server with `java -jar concord-server.jar`, it will generate a `server-config.json` configuration file, and a `concord-server.db` database file. + +## Configuring the Server + +You probably want to customize your server a bit. To do so, first stop your server by typing `stop` in the console where you started the server initially. Now you can edit `server-config.json` and restart the server once you're done. A description of the attributes is given below: + +- `name` The name of the server. +- `description` A short description of what this server is for, or who it's run by. +- `port` The port on which the server accepts client connections. +- `chatHistoryMaxCount` The maximum amount of chat messages that a client can request from the server at any given time. Decrease this to improve performance. +- `chatHistoryDefaultCount` The default number of chat messages that are provided to clients when they join a channel, if they don't explicitly request a certain amount. Decrease this to improve performance. +- `maxMessageLength` The maximum length of a message. Messages longer than this will be rejected. +- `channels` Contains a list of all channels that the server uses. Each channel has an `id`, `name`, and `description`. **It is advised that you do not add or remove channels manually!** Instead, use the `add-channel` and `remove-channel` CLI commands that are available while the server is running. +- `discoveryServers` A list of URLs to which this server should send its metadata for publishing. Keep this empty if you don't want your server to be publicly visible. + +## Server CLI + +As mentioned briefly, the server supports a basic command-line-interface with some commands. You can show which commands are available via the `help` command. The following is a list of some of the most useful commands and a description of their functionality: + +- `add-channel ` Adds a new channel to the server with the given name. Channel names cannot be blank, and they cannot be duplicates of an existing channel name. +- `remove-channel ` Removes a channel. +- `list-clients` Shows a list of all connected clients. +- `stop` Stops the server, disconnecting all clients. + Each server uses a single [Nitrite](https://www.dizitart.org/nitrite-database/#what-is-nitrite) database to hold messages and other information. diff --git a/client/pom.xml b/client/pom.xml index 813d74e..be7bf11 100644 --- a/client/pom.xml +++ b/client/pom.xml @@ -23,6 +23,16 @@ lanterna 3.2.0-alpha1 + + net.java.dev.jna + jna + 5.9.0 + + + net.java.dev.jna + jna-platform + 5.9.0 + diff --git a/client/src/main/java/module-info.java b/client/src/main/java/module-info.java index a6447ae..bca82e3 100644 --- a/client/src/main/java/module-info.java +++ b/client/src/main/java/module-info.java @@ -1,5 +1,7 @@ module concord_client { requires concord_core; requires com.googlecode.lanterna; + requires com.sun.jna; + requires com.sun.jna.platform; requires static lombok; } \ No newline at end of file diff --git a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java index 90c672d..8a566be 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java +++ b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java @@ -17,6 +17,15 @@ public class ChannelManager { 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() { 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 e2e595d..10fe69d 100644 --- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java +++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java @@ -1,8 +1,5 @@ package nl.andrewl.concord_server; -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.databind.node.ObjectNode; import lombok.Getter; import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Serializer; @@ -16,15 +13,9 @@ import org.dizitart.no2.Nitrite; import java.io.ByteArrayOutputStream; import java.io.IOException; -import java.io.UncheckedIOException; import java.net.ServerSocket; import java.net.Socket; -import java.net.URI; -import java.net.http.HttpClient; -import java.net.http.HttpRequest; -import java.net.http.HttpResponse; import java.nio.file.Path; -import java.time.Duration; import java.util.Comparator; import java.util.List; import java.util.Map; @@ -36,45 +27,62 @@ import java.util.stream.Collectors; * The main server implementation, which handles accepting new clients. */ 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; + /** + * Server configuration data. This is used to define channels, discovery + * server addresses, and more. + */ @Getter private final ServerConfig config; + + /** + * The component that generates new user and channel ids. + */ @Getter private final IdProvider idProvider; + + /** + * The database that contains all messages and other server information. + */ @Getter private final Nitrite db; + + /** + * A general-purpose executor service that can be used to submit async tasks. + */ @Getter - private final ExecutorService executorService; + private final ExecutorService executorService = Executors.newCachedThreadPool(); + + /** + * Manager that handles incoming messages and events by clients. + */ @Getter private final EventManager eventManager; + + /** + * Manager that handles the collection of channels in this server. + */ @Getter private final ChannelManager channelManager; - // Components for communicating with discovery servers. + private final DiscoveryServerPublisher discoveryServerPublisher; private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor(); - private final HttpClient httpClient = HttpClient.newHttpClient(); - private final ObjectMapper mapper = new ObjectMapper(); - public ConcordServer() { + public ConcordServer() throws IOException { this.idProvider = new UUIDProvider(); - this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider); - this.db = Nitrite.builder() - .filePath("concord-server.db") - .openOrCreate(); + 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.executorService = Executors.newCachedThreadPool(); this.eventManager = new EventManager(this); this.channelManager = new ChannelManager(this); - for (var channelConfig : config.getChannels()) { - this.channelManager.addChannel(new Channel( - this, - UUID.fromString(channelConfig.getId()), - channelConfig.getName(), - this.db.getCollection("channel-" + channelConfig.getId()) - )); - } + this.serverSocket = new ServerSocket(this.config.getPort()); } /** @@ -115,10 +123,29 @@ public class ConcordServer implements Runnable { } } + /** + * @return True if the server is currently running, meaning it is accepting + * connections, or false otherwise. + */ public boolean isRunning() { return running; } + /** + * Stops the server. Has no effect if the server has not started yet or has + * already been stopped. + */ + public void stop() { + this.running = false; + if (!this.serverSocket.isClosed()) { + try { + this.serverSocket.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } + } + public List getClients() { return this.clients.values().stream() .sorted(Comparator.comparing(ClientThread::getClientNickname)) @@ -153,64 +180,54 @@ public class ConcordServer implements Runnable { } } - private void publishMetaDataToDiscoveryServers() { - if (this.config.getDiscoveryServers().isEmpty()) return; - ObjectNode node = this.mapper.createObjectNode(); - node.put("name", this.config.getName()); - node.put("description", this.config.getDescription()); - node.put("port", this.config.getPort()); - String json; - try { - json = this.mapper.writeValueAsString(node); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); + /** + * Shuts down the server cleanly by doing the following things: + *
    + *
  1. Disconnecting all clients.
  2. + *
  3. Shutting down any executor services.
  4. + *
  5. Flushing and compacting the message database.
  6. + *
  7. Flushing the server configuration one last time.
  8. + *
+ */ + private void shutdown() { + System.out.println("Shutting down the server."); + for (var clientId : this.clients.keySet()) { + this.deregisterClient(clientId); } - var discoveryServers = List.copyOf(this.config.getDiscoveryServers()); - for (var discoveryServer : discoveryServers) { - System.out.println("Publishing this server's metadata to discovery server at " + discoveryServer); - var request = HttpRequest.newBuilder(URI.create(discoveryServer)) - .POST(HttpRequest.BodyPublishers.ofString(json)) - .header("Content-Type", "application/json") - .timeout(Duration.ofSeconds(3)) - .build(); - try { - this.httpClient.send(request, HttpResponse.BodyHandlers.discarding()); - } catch (IOException | InterruptedException e) { - e.printStackTrace(); - } + this.scheduledExecutorService.shutdown(); + this.executorService.shutdown(); + this.db.close(); + try { + this.config.save(); + } catch (IOException e) { + System.err.println("Could not save configuration on shutdown: " + e.getMessage()); } } @Override public void run() { this.running = true; - this.scheduledExecutorService.scheduleAtFixedRate(this::publishMetaDataToDiscoveryServers, 0, 1, TimeUnit.MINUTES); - ServerSocket serverSocket; - try { - serverSocket = new ServerSocket(this.config.getPort()); - StringBuilder startupMessage = new StringBuilder(); - startupMessage.append("Opened server on port ").append(config.getPort()).append("\n"); - for (var channel : this.channelManager.getChannels()) { - startupMessage.append("\tChannel \"").append(channel).append('\n'); - } - System.out.println(startupMessage); - while (this.running) { - try { - Socket socket = serverSocket.accept(); - ClientThread clientThread = new ClientThread(socket, this); - clientThread.start(); - } catch (IOException e) { - e.printStackTrace(); - } - } - serverSocket.close(); - } catch (IOException e) { - System.err.println("Could not open server socket: " + e.getMessage()); + this.scheduledExecutorService.scheduleAtFixedRate(this.discoveryServerPublisher::publish, 0, 1, TimeUnit.MINUTES); + StringBuilder startupMessage = new StringBuilder(); + startupMessage.append("Opened server on port ").append(config.getPort()).append("\n") + .append("The following channels are available:\n"); + for (var channel : this.channelManager.getChannels()) { + startupMessage.append("\tChannel \"").append(channel).append('\n'); } - this.scheduledExecutorService.shutdown(); + System.out.println(startupMessage); + while (this.running) { + try { + Socket socket = this.serverSocket.accept(); + ClientThread clientThread = new ClientThread(socket, this); + clientThread.start(); + } catch (IOException e) { + System.err.println("Could not accept new client connection: " + e.getMessage()); + } + } + this.shutdown(); } - public static void main(String[] args) { + public static void main(String[] args) throws IOException { var server = new ConcordServer(); new Thread(server).start(); new ServerCli(server).run(); diff --git a/server/src/main/java/nl/andrewl/concord_server/DiscoveryServerPublisher.java b/server/src/main/java/nl/andrewl/concord_server/DiscoveryServerPublisher.java new file mode 100644 index 0000000..d29cce7 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/DiscoveryServerPublisher.java @@ -0,0 +1,56 @@ +package nl.andrewl.concord_server; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import nl.andrewl.concord_server.config.ServerConfig; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; + +/** + * This component is responsible for publishing the server's metadata to any + * discovery servers that have been defined in the server's configuration file. + */ +public class DiscoveryServerPublisher { + private final ObjectMapper mapper = new ObjectMapper(); + private final HttpClient httpClient = HttpClient.newHttpClient(); + private final ServerConfig config; + + public DiscoveryServerPublisher(ServerConfig config) { + this.config = config; + } + + public void publish() { + if (this.config.getDiscoveryServers().isEmpty()) return; + ObjectNode node = this.mapper.createObjectNode(); + node.put("name", this.config.getName()); + node.put("description", this.config.getDescription()); + node.put("port", this.config.getPort()); + String json; + try { + json = this.mapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + var discoveryServers = List.copyOf(this.config.getDiscoveryServers()); + for (var discoveryServer : discoveryServers) { + var request = HttpRequest.newBuilder(URI.create(discoveryServer)) + .POST(HttpRequest.BodyPublishers.ofString(json)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(3)) + .build(); + try { + this.httpClient.send(request, HttpResponse.BodyHandlers.discarding()); + } catch (IOException | InterruptedException e) { + System.err.println("Could not publish metadata to " + discoveryServer + " because of exception: " + e.getClass().getSimpleName()); + } + } + } +} diff --git a/server/src/main/java/nl/andrewl/concord_server/cli/ServerCli.java b/server/src/main/java/nl/andrewl/concord_server/cli/ServerCli.java index 80f4e8a..4aca00a 100644 --- a/server/src/main/java/nl/andrewl/concord_server/cli/ServerCli.java +++ b/server/src/main/java/nl/andrewl/concord_server/cli/ServerCli.java @@ -4,6 +4,7 @@ import nl.andrewl.concord_server.ConcordServer; import nl.andrewl.concord_server.cli.command.AddChannelCommand; import nl.andrewl.concord_server.cli.command.ListClientsCommand; import nl.andrewl.concord_server.cli.command.RemoveChannelCommand; +import nl.andrewl.concord_server.cli.command.StopCommand; import java.io.BufferedReader; import java.io.InputStreamReader; @@ -22,10 +23,19 @@ public class ServerCli implements Runnable { this.commands.put("list-clients", new ListClientsCommand()); this.commands.put("add-channel", new AddChannelCommand()); this.commands.put("remove-channel", new RemoveChannelCommand()); + this.commands.put("stop", new StopCommand()); + + this.commands.put("help", (s, args) -> { + System.out.println("The following commands are available:"); + for (var key : commands.keySet().stream().sorted().toList()) { + System.out.println("\t" + key); + } + }); } @Override public void run() { + System.out.println("Server command-line-interface initialized. Type \"help\" for a list of available commands."); BufferedReader reader = new BufferedReader(new InputStreamReader(System.in)); String line; try { diff --git a/server/src/main/java/nl/andrewl/concord_server/cli/command/StopCommand.java b/server/src/main/java/nl/andrewl/concord_server/cli/command/StopCommand.java new file mode 100644 index 0000000..807dcf0 --- /dev/null +++ b/server/src/main/java/nl/andrewl/concord_server/cli/command/StopCommand.java @@ -0,0 +1,14 @@ +package nl.andrewl.concord_server.cli.command; + +import nl.andrewl.concord_server.ConcordServer; +import nl.andrewl.concord_server.cli.ServerCliCommand; + +/** + * This command forcibly stops the server, disconnecting any clients. + */ +public class StopCommand implements ServerCliCommand { + @Override + public void handle(ConcordServer server, String[] args) throws Exception { + server.stop(); + } +}