Added catalog discovery server, readmes and improved some server channel logic.
This commit is contained in:
parent
31129cada2
commit
e9fa0c13a7
|
@ -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.
|
|
@ -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>
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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());
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
port=25566
|
|
@ -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.
|
|
@ -22,7 +22,7 @@ public class ChatRenderer extends AbstractListBox.ListItemRenderer<Chat, ChatLis
|
|||
}
|
||||
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 timeStr = timestamp.atZone(ZoneId.systemDefault()).format(DateTimeFormatter.ofPattern("HH:mm"));
|
||||
String label = chat.getSenderNickname() + "@" + timeStr + " : " + chat.getMessage();
|
||||
label = TerminalTextUtils.fitString(label, graphics.getSize().getColumns());
|
||||
while(TerminalTextUtils.getColumnWidth(label) < graphics.getSize().getColumns()) {
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
# Concord Core
|
||||
|
||||
This module contains the core resources that both the client and server depend on.
|
1
pom.xml
1
pom.xml
|
@ -12,6 +12,7 @@
|
|||
<module>server</module>
|
||||
<module>core</module>
|
||||
<module>client</module>
|
||||
<module>catalog</module>
|
||||
</modules>
|
||||
|
||||
<properties>
|
||||
|
|
|
@ -7,6 +7,7 @@ module concord_server {
|
|||
|
||||
requires java.base;
|
||||
requires java.logging;
|
||||
requires java.net.http;
|
||||
|
||||
requires concord_core;
|
||||
|
||||
|
|
|
@ -23,6 +23,17 @@ public class ChannelManager {
|
|||
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) {
|
||||
this.channelNameMap.put(channel.getName(), channel);
|
||||
this.channelIdMap.put(channel.getId(), channel);
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<ChannelConfig> channels;
|
||||
|
||||
private List<String> 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)) {
|
||||
|
|
Loading…
Reference in New Issue