diff --git a/catalog/README.md b/catalog/README.md
new file mode 100644
index 0000000..efaa5ea
--- /dev/null
+++ b/catalog/README.md
@@ -0,0 +1,3 @@
+# Concord Catalog
+
+The catalog is an HTTP server that is used as a "discovery" server that connects clients to the concord servers they might want to join. Clients will request a list of servers from the catalog, and servers are responsible for regularly sending their metadata to any catalogs they wish to be publicly visible in.
diff --git a/catalog/pom.xml b/catalog/pom.xml
new file mode 100644
index 0000000..fac9f56
--- /dev/null
+++ b/catalog/pom.xml
@@ -0,0 +1,36 @@
+
+
+
+ concord
+ nl.andrewl
+ 1.0-SNAPSHOT
+
+ 4.0.0
+
+ concord-catalog
+
+
+ 16
+ 16
+
+
+
+
+ io.undertow
+ undertow-core
+ 2.2.8.Final
+
+
+ io.undertow
+ undertow-servlet
+ 2.2.8.Final
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.12.4
+
+
+
\ No newline at end of file
diff --git a/catalog/src/main/java/module-info.java b/catalog/src/main/java/module-info.java
new file mode 100644
index 0000000..6be15f8
--- /dev/null
+++ b/catalog/src/main/java/module-info.java
@@ -0,0 +1,10 @@
+module concord_catalog {
+ requires com.fasterxml.jackson.databind;
+ requires undertow.core;
+ requires undertow.servlet;
+ requires java.servlet;
+ requires jdk.unsupported;
+
+ exports nl.andrewl.concord_catalog.servlet to undertow.servlet;
+ opens nl.andrewl.concord_catalog.servlet to com.fasterxml.jackson.databind;
+}
\ No newline at end of file
diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/CatalogServer.java b/catalog/src/main/java/nl/andrewl/concord_catalog/CatalogServer.java
new file mode 100644
index 0000000..b16188f
--- /dev/null
+++ b/catalog/src/main/java/nl/andrewl/concord_catalog/CatalogServer.java
@@ -0,0 +1,65 @@
+package nl.andrewl.concord_catalog;
+
+import io.undertow.Undertow;
+import io.undertow.server.HttpHandler;
+import io.undertow.servlet.Servlets;
+import io.undertow.servlet.api.DeploymentInfo;
+import io.undertow.servlet.api.DeploymentManager;
+import nl.andrewl.concord_catalog.servlet.ServersServlet;
+
+import javax.servlet.ServletException;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.Properties;
+
+public class CatalogServer {
+ private static final String SETTINGS_FILE = "concord-catalog.properties";
+
+ public static void main(String[] args) throws ServletException, IOException {
+ var props = loadProperties();
+ startServer(Integer.parseInt(props.getProperty("port")));
+ }
+
+ /**
+ * Starts the Undertow HTTP servlet container.
+ * @param port The port to bind to.
+ * @throws ServletException If the server could not be started.
+ */
+ private static void startServer(int port) throws ServletException {
+ System.out.println("Starting server on port " + port + ".");
+ DeploymentInfo servletBuilder = Servlets.deployment()
+ .setClassLoader(CatalogServer.class.getClassLoader())
+ .setContextPath("/")
+ .setDeploymentName("Concord Catalog")
+ .addServlets(
+ Servlets.servlet("ServersServlet", ServersServlet.class)
+ .addMapping("/servers")
+ );
+ DeploymentManager manager = Servlets.defaultContainer().addDeployment(servletBuilder);
+ manager.deploy();
+ HttpHandler servletHandler = manager.start();
+ Undertow server = Undertow.builder()
+ .addHttpListener(port, "0.0.0.0")
+ .setHandler(servletHandler)
+ .build();
+ server.start();
+ }
+
+ /**
+ * Loads properties from all necessary locations.
+ * @return The properties that were loaded.
+ * @throws IOException If an error occurs while reading properties.
+ */
+ private static Properties loadProperties() throws IOException {
+ Properties props = new Properties();
+ props.load(CatalogServer.class.getResourceAsStream("/nl/andrewl/concord_catalog/defaults.properties"));
+ Path settingsPath = Path.of(SETTINGS_FILE);
+ if (Files.exists(settingsPath)) {
+ props.load(Files.newBufferedReader(settingsPath));
+ } else {
+ System.out.println("Using built-in default settings. Create a concord-catalog.properties file to configure.");
+ }
+ return props;
+ }
+}
diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/Page.java b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/Page.java
new file mode 100644
index 0000000..9a511f2
--- /dev/null
+++ b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/Page.java
@@ -0,0 +1,57 @@
+package nl.andrewl.concord_catalog.servlet;
+
+import java.util.List;
+
+public class Page {
+ private final List contents;
+ private final int elementCount;
+ private final int pageSize;
+ private final int currentPage;
+ private final boolean firstPage;
+ private final boolean lastPage;
+ private final String order;
+ private final String orderDirection;
+
+ public Page(List contents, int currentPage, int pageSize, String order, String orderDirection) {
+ this.contents = contents;
+ this.elementCount = contents.size();
+ this.pageSize = pageSize;
+ this.currentPage = currentPage;
+ this.firstPage = currentPage == 0;
+ this.lastPage = this.elementCount < this.pageSize;
+ this.order = order;
+ this.orderDirection = orderDirection;
+ }
+
+ public List getContents() {
+ return contents;
+ }
+
+ public int getElementCount() {
+ return elementCount;
+ }
+
+ public int getPageSize() {
+ return pageSize;
+ }
+
+ public int getCurrentPage() {
+ return currentPage;
+ }
+
+ public boolean isFirstPage() {
+ return firstPage;
+ }
+
+ public boolean isLastPage() {
+ return lastPage;
+ }
+
+ public String getOrder() {
+ return order;
+ }
+
+ public String getOrderDirection() {
+ return orderDirection;
+ }
+}
diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServerMetaData.java b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServerMetaData.java
new file mode 100644
index 0000000..e65e342
--- /dev/null
+++ b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServerMetaData.java
@@ -0,0 +1,82 @@
+package nl.andrewl.concord_catalog.servlet;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+
+import java.util.Objects;
+
+public class ServerMetaData implements Comparable {
+ private String name;
+ private String description;
+ private String host;
+ private int port;
+
+ @JsonIgnore
+ private long lastUpdatedAt;
+
+ public String getName() {
+ return name;
+ }
+
+ public void setName(String name) {
+ this.name = name;
+ }
+
+ public String getDescription() {
+ return description;
+ }
+
+ public void setDescription(String description) {
+ this.description = description;
+ }
+
+ public String getHost() {
+ return host;
+ }
+
+ public void setHost(String host) {
+ this.host = host;
+ }
+
+ public int getPort() {
+ return port;
+ }
+
+ public void setPort(int port) {
+ this.port = port;
+ }
+
+ public String getAddress() {
+ return this.host + ":" + this.port;
+ }
+
+ public long getLastUpdatedAt() {
+ return lastUpdatedAt;
+ }
+
+ public void setLastUpdatedAt(long lastUpdatedAt) {
+ this.lastUpdatedAt = lastUpdatedAt;
+ }
+
+ @Override
+ public int compareTo(ServerMetaData o) {
+ int result = this.name.compareTo(o.getName());
+ if (result == 0) {
+ result = this.getAddress().compareTo(o.getAddress());
+ }
+ return result;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (o.getClass().equals(this.getClass())) {
+ ServerMetaData other = (ServerMetaData) o;
+ return this.name.equals(other.getName()) && this.getAddress().equals(other.getAddress());
+ }
+ return false;
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getName(), getAddress());
+ }
+}
diff --git a/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServersServlet.java b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServersServlet.java
new file mode 100644
index 0000000..f86a3d4
--- /dev/null
+++ b/catalog/src/main/java/nl/andrewl/concord_catalog/servlet/ServersServlet.java
@@ -0,0 +1,57 @@
+package nl.andrewl.concord_catalog.servlet;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.util.Map;
+import java.util.SortedSet;
+import java.util.TreeSet;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * This servlet is the main HTTP endpoint for getting the list of servers and
+ * also uploading one's own server data.
+ */
+public class ServersServlet extends HttpServlet {
+ private static final SortedSet servers = new TreeSet<>();
+ private static final ObjectMapper mapper = new ObjectMapper();
+ private static final ScheduledExecutorService executorService = Executors.newSingleThreadScheduledExecutor();
+ static {
+ executorService.scheduleAtFixedRate(() -> {
+ long now = System.currentTimeMillis();
+ servers.removeIf(server -> server.getLastUpdatedAt() < now - (5 * 60000));
+ }, 1, 1, TimeUnit.MINUTES);
+ }
+
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
+ resp.setStatus(HttpServletResponse.SC_OK);
+ resp.setContentType("application/json");
+ mapper.writeValue(resp.getWriter(), servers);
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
+ if (servers.size() > 10000) {
+ resp.setStatus(HttpServletResponse.SC_BAD_REQUEST);
+ resp.setContentType("application/json");
+ mapper.writeValue(resp.getWriter(), Map.of("message", "Too many servers registered at this time."));
+ return;
+ }
+ ServerMetaData data = mapper.readValue(req.getReader(), ServerMetaData.class);
+ data.setHost(req.getRemoteHost());
+ data.setLastUpdatedAt(System.currentTimeMillis());
+ synchronized (servers) {
+ servers.remove(data);
+ servers.add(data);
+ }
+ resp.setStatus(HttpServletResponse.SC_OK);
+ resp.setContentType("application/json");
+ mapper.writeValue(resp.getWriter(), data);
+ }
+}
diff --git a/catalog/src/main/resources/nl/andrewl/concord_catalog/defaults.properties b/catalog/src/main/resources/nl/andrewl/concord_catalog/defaults.properties
new file mode 100644
index 0000000..ee45159
--- /dev/null
+++ b/catalog/src/main/resources/nl/andrewl/concord_catalog/defaults.properties
@@ -0,0 +1 @@
+port=25566
\ No newline at end of file
diff --git a/client/README.md b/client/README.md
new file mode 100644
index 0000000..c3251b9
--- /dev/null
+++ b/client/README.md
@@ -0,0 +1,3 @@
+# Concord Client
+
+The concord client is the application which users will run to connect to and interact with various servers. It displays a GUI in the terminal that shows recent chat messages, channels, and the list of users in a channel.
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
index 36066b2..d3678e5 100644
--- a/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java
+++ b/client/src/main/java/nl/andrewl/concord_client/gui/ChatRenderer.java
@@ -22,8 +22,8 @@ public class ChatRenderer extends AbstractListBox.ListItemRendererserver
core
client
+ catalog
diff --git a/server/src/main/java/module-info.java b/server/src/main/java/module-info.java
index 9e45cf7..6552aac 100644
--- a/server/src/main/java/module-info.java
+++ b/server/src/main/java/module-info.java
@@ -7,6 +7,7 @@ module concord_server {
requires java.base;
requires java.logging;
+ requires java.net.http;
requires concord_core;
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 f07e714..90c672d 100644
--- a/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java
+++ b/server/src/main/java/nl/andrewl/concord_server/ChannelManager.java
@@ -23,6 +23,17 @@ public class ChannelManager {
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);
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 0ed6e67..5af63e6 100644
--- a/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java
+++ b/server/src/main/java/nl/andrewl/concord_server/ConcordServer.java
@@ -1,5 +1,8 @@
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;
@@ -12,18 +15,21 @@ import nl.andrewl.concord_server.config.ServerConfig;
import org.dizitart.no2.Nitrite;
import java.io.ByteArrayOutputStream;
-import java.io.DataOutputStream;
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;
import java.util.UUID;
-import java.util.concurrent.ConcurrentHashMap;
-import java.util.concurrent.ExecutorService;
-import java.util.concurrent.Executors;
+import java.util.concurrent.*;
import java.util.stream.Collectors;
/**
@@ -46,6 +52,11 @@ public class ConcordServer implements Runnable {
@Getter
private final ChannelManager channelManager;
+ // Components for communicating with discovery servers.
+ private final ScheduledExecutorService scheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
+ private final HttpClient httpClient = HttpClient.newHttpClient();
+ private final ObjectMapper mapper = new ObjectMapper();
+
public ConcordServer() {
this.idProvider = new UUIDProvider();
this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider);
@@ -53,7 +64,6 @@ public class ConcordServer implements Runnable {
.filePath("concord-server.db")
.openOrCreate();
this.clients = new ConcurrentHashMap<>(32);
-
this.executorService = Executors.newCachedThreadPool();
this.eventManager = new EventManager(this);
this.channelManager = new ChannelManager(this);
@@ -83,7 +93,7 @@ public class ConcordServer implements Runnable {
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.getChannelByName("general").orElseThrow();
+ 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);
@@ -143,9 +153,38 @@ 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);
+ }
+ 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();
+ }
+ }
+ }
+
@Override
public void run() {
this.running = true;
+ this.scheduledExecutorService.scheduleAtFixedRate(this::publishMetaDataToDiscoveryServers, 1, 1, TimeUnit.MINUTES);
ServerSocket serverSocket;
try {
serverSocket = new ServerSocket(this.config.getPort());
@@ -163,6 +202,7 @@ public class ConcordServer implements Runnable {
} catch (IOException e) {
e.printStackTrace();
}
+ this.scheduledExecutorService.shutdown();
}
public static void main(String[] args) {
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 19e0fb9..5ee7afd 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
@@ -39,5 +39,6 @@ public class RemoveChannelCommand implements ServerCliCommand {
server.getConfig().getChannels().removeIf(channelConfig -> channelConfig.getName().equals(channelToRemove.getName()));
server.getConfig().save();
server.broadcast(server.getMetaData());
+ System.out.println("Removed the channel " + channelToRemove);
}
}
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 1a8fc4a..ea76465 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
@@ -18,12 +18,15 @@ import java.util.List;
@AllArgsConstructor
public final class ServerConfig {
private String name;
+ private String description;
private int port;
private int chatHistoryMaxCount;
private int chatHistoryDefaultCount;
private int maxMessageLength;
private List channels;
+ private List discoveryServers;
+
/**
* The path at which this config is stored.
*/
@@ -45,11 +48,13 @@ public final class ServerConfig {
if (Files.notExists(filePath)) {
config = new ServerConfig(
"My Concord Server",
+ "A concord server for my friends and I.",
8123,
100,
50,
8192,
List.of(new ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")),
+ List.of(),
filePath
);
try (var out = Files.newOutputStream(filePath)) {