Added catalog discovery server, readmes and improved some server channel logic.

This commit is contained in:
Andrew Lalis 2021-08-26 11:22:24 +02:00
parent 31129cada2
commit e9fa0c13a7
17 changed files with 384 additions and 8 deletions

3
catalog/README.md Normal file
View File

@ -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.

36
catalog/pom.xml Normal file
View File

@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>concord</artifactId>
<groupId>nl.andrewl</groupId>
<version>1.0-SNAPSHOT</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>concord-catalog</artifactId>
<properties>
<maven.compiler.source>16</maven.compiler.source>
<maven.compiler.target>16</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>io.undertow</groupId>
<artifactId>undertow-core</artifactId>
<version>2.2.8.Final</version>
</dependency>
<dependency>
<groupId>io.undertow</groupId>
<artifactId>undertow-servlet</artifactId>
<version>2.2.8.Final</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.12.4</version>
</dependency>
</dependencies>
</project>

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,57 @@
package nl.andrewl.concord_catalog.servlet;
import java.util.List;
public class Page<T> {
private final List<T> 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<T> 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<T> 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;
}
}

View File

@ -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<ServerMetaData> {
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());
}
}

View File

@ -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<ServerMetaData> 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);
}
}

View File

@ -0,0 +1 @@
port=25566

3
client/README.md Normal file
View File

@ -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.

View File

@ -22,8 +22,8 @@ public class ChatRenderer extends AbstractListBox.ListItemRenderer<Chat, ChatLis
} }
graphics.putString(0, 0, chat.getSenderNickname()); graphics.putString(0, 0, chat.getSenderNickname());
Instant timestamp = Instant.ofEpochMilli(chat.getTimestamp()); Instant timestamp = Instant.ofEpochMilli(chat.getTimestamp());
String timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm:ss")); String timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm"));
String label = chat.getSenderNickname() + " @ " + timeStr + " : " + chat.getMessage(); String label = chat.getSenderNickname() + "@" + timeStr + " : " + chat.getMessage();
label = TerminalTextUtils.fitString(label, graphics.getSize().getColumns()); label = TerminalTextUtils.fitString(label, graphics.getSize().getColumns());
while(TerminalTextUtils.getColumnWidth(label) < graphics.getSize().getColumns()) { while(TerminalTextUtils.getColumnWidth(label) < graphics.getSize().getColumns()) {
label += " "; label += " ";

3
core/README.md Normal file
View File

@ -0,0 +1,3 @@
# Concord Core
This module contains the core resources that both the client and server depend on.

View File

@ -12,6 +12,7 @@
<module>server</module> <module>server</module>
<module>core</module> <module>core</module>
<module>client</module> <module>client</module>
<module>catalog</module>
</modules> </modules>
<properties> <properties>

View File

@ -7,6 +7,7 @@ module concord_server {
requires java.base; requires java.base;
requires java.logging; requires java.logging;
requires java.net.http;
requires concord_core; requires concord_core;

View File

@ -23,6 +23,17 @@ public class ChannelManager {
return Set.copyOf(this.channelIdMap.values()); return Set.copyOf(this.channelIdMap.values());
} }
public Optional<Channel> 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) { public void addChannel(Channel channel) {
this.channelNameMap.put(channel.getName(), channel); this.channelNameMap.put(channel.getName(), channel);
this.channelIdMap.put(channel.getId(), channel); this.channelIdMap.put(channel.getId(), channel);

View File

@ -1,5 +1,8 @@
package nl.andrewl.concord_server; 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 lombok.Getter;
import nl.andrewl.concord_core.msg.Message; import nl.andrewl.concord_core.msg.Message;
import nl.andrewl.concord_core.msg.Serializer; import nl.andrewl.concord_core.msg.Serializer;
@ -12,18 +15,21 @@ import nl.andrewl.concord_server.config.ServerConfig;
import org.dizitart.no2.Nitrite; import org.dizitart.no2.Nitrite;
import java.io.ByteArrayOutputStream; import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.UncheckedIOException;
import java.net.ServerSocket; import java.net.ServerSocket;
import java.net.Socket; 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.nio.file.Path;
import java.time.Duration;
import java.util.Comparator; import java.util.Comparator;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.*;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@ -46,6 +52,11 @@ public class ConcordServer implements Runnable {
@Getter @Getter
private final ChannelManager channelManager; 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() { public ConcordServer() {
this.idProvider = new UUIDProvider(); this.idProvider = new UUIDProvider();
this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider); this.config = ServerConfig.loadOrCreate(Path.of("server-config.json"), idProvider);
@ -53,7 +64,6 @@ public class ConcordServer implements Runnable {
.filePath("concord-server.db") .filePath("concord-server.db")
.openOrCreate(); .openOrCreate();
this.clients = new ConcurrentHashMap<>(32); this.clients = new ConcurrentHashMap<>(32);
this.executorService = Executors.newCachedThreadPool(); this.executorService = Executors.newCachedThreadPool();
this.eventManager = new EventManager(this); this.eventManager = new EventManager(this);
this.channelManager = new ChannelManager(this); this.channelManager = new ChannelManager(this);
@ -83,7 +93,7 @@ public class ConcordServer implements Runnable {
clientThread.setClientId(id); clientThread.setClientId(id);
clientThread.setClientNickname(identification.getNickname()); clientThread.setClientNickname(identification.getNickname());
// Immediately add the client to the default channel and send the initial welcome message. // 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())); 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. // 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); 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 @Override
public void run() { public void run() {
this.running = true; this.running = true;
this.scheduledExecutorService.scheduleAtFixedRate(this::publishMetaDataToDiscoveryServers, 1, 1, TimeUnit.MINUTES);
ServerSocket serverSocket; ServerSocket serverSocket;
try { try {
serverSocket = new ServerSocket(this.config.getPort()); serverSocket = new ServerSocket(this.config.getPort());
@ -163,6 +202,7 @@ public class ConcordServer implements Runnable {
} catch (IOException e) { } catch (IOException e) {
e.printStackTrace(); e.printStackTrace();
} }
this.scheduledExecutorService.shutdown();
} }
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -39,5 +39,6 @@ public class RemoveChannelCommand implements ServerCliCommand {
server.getConfig().getChannels().removeIf(channelConfig -> channelConfig.getName().equals(channelToRemove.getName())); server.getConfig().getChannels().removeIf(channelConfig -> channelConfig.getName().equals(channelToRemove.getName()));
server.getConfig().save(); server.getConfig().save();
server.broadcast(server.getMetaData()); server.broadcast(server.getMetaData());
System.out.println("Removed the channel " + channelToRemove);
} }
} }

View File

@ -18,12 +18,15 @@ import java.util.List;
@AllArgsConstructor @AllArgsConstructor
public final class ServerConfig { public final class ServerConfig {
private String name; private String name;
private String description;
private int port; private int port;
private int chatHistoryMaxCount; private int chatHistoryMaxCount;
private int chatHistoryDefaultCount; private int chatHistoryDefaultCount;
private int maxMessageLength; private int maxMessageLength;
private List<ChannelConfig> channels; private List<ChannelConfig> channels;
private List<String> discoveryServers;
/** /**
* The path at which this config is stored. * The path at which this config is stored.
*/ */
@ -45,11 +48,13 @@ public final class ServerConfig {
if (Files.notExists(filePath)) { if (Files.notExists(filePath)) {
config = new ServerConfig( config = new ServerConfig(
"My Concord Server", "My Concord Server",
"A concord server for my friends and I.",
8123, 8123,
100, 100,
50, 50,
8192, 8192,
List.of(new ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")), List.of(new ChannelConfig(idProvider.newId().toString(), "general", "Default channel for general discussion.")),
List.of(),
filePath filePath
); );
try (var out = Files.newOutputStream(filePath)) { try (var out = Files.newOutputStream(filePath)) {