diff --git a/.gitignore b/.gitignore
index 4d76962..832142b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,5 @@
client/target/
core/target/
server/target/
+server-registry/target/
/*.iml
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
index 187fae8..016bc2a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -12,6 +12,7 @@
server
client
core
+ server-registry
diff --git a/server-registry/pom.xml b/server-registry/pom.xml
new file mode 100644
index 0000000..0ae42d3
--- /dev/null
+++ b/server-registry/pom.xml
@@ -0,0 +1,73 @@
+
+
+
+ ace-of-shades
+ nl.andrewlalis
+ 4.0
+
+ 4.0.0
+
+ server-registry
+
+
+ 16
+ 16
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-assembly-plugin
+ 3.3.0
+
+
+
+ nl.andrewlalis.aos_server_registry.ServerRegistry
+
+
+
+ jar-with-dependencies
+
+
+
+
+ make-assembly
+ package
+
+ single
+
+
+
+
+
+
+
+
+
+ io.undertow
+ undertow-core
+ 2.2.8.Final
+
+
+ io.undertow
+ undertow-servlet
+ 2.2.8.Final
+
+
+
+ com.fasterxml.jackson.core
+ jackson-databind
+ 2.12.3
+
+
+
+ com.h2database
+ h2
+ 1.4.200
+
+
+
+
\ No newline at end of file
diff --git a/server-registry/src/main/java/module-info.java b/server-registry/src/main/java/module-info.java
new file mode 100644
index 0000000..22a7631
--- /dev/null
+++ b/server-registry/src/main/java/module-info.java
@@ -0,0 +1,14 @@
+module aos_server_registry {
+ requires undertow.core;
+ requires undertow.servlet;
+ requires jdk.unsupported; // Needed for undertow support.
+ requires java.servlet;
+ requires com.fasterxml.jackson.databind;
+ requires com.h2database;
+ requires java.sql;
+
+ opens nl.andrewlalis.aos_server_registry to com.fasterxml.jackson.databind;
+ opens nl.andrewlalis.aos_server_registry.servlet to com.fasterxml.jackson.databind;
+ exports nl.andrewlalis.aos_server_registry.servlet to undertow.servlet;
+ opens nl.andrewlalis.aos_server_registry.servlet.dto to com.fasterxml.jackson.databind;
+}
\ No newline at end of file
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/ServerRegistry.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/ServerRegistry.java
new file mode 100644
index 0000000..5c2171f
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/ServerRegistry.java
@@ -0,0 +1,46 @@
+package nl.andrewlalis.aos_server_registry;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+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.andrewlalis.aos_server_registry.data.ServerDataPruner;
+import nl.andrewlalis.aos_server_registry.servlet.ServerInfoServlet;
+
+import javax.servlet.ServletException;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+public class ServerRegistry {
+ public static final int PORT = 8567;
+ public static final ObjectMapper mapper = new ObjectMapper();
+
+ public static void main(String[] args) throws ServletException {
+ startServer();
+ // Every few minutes, prune all stale servers from the registry.
+ ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(3);
+ scheduler.scheduleAtFixedRate(new ServerDataPruner(), 1, 1, TimeUnit.MINUTES);
+ }
+
+ private static void startServer() throws ServletException {
+ DeploymentInfo servletBuilder = Servlets.deployment()
+ .setClassLoader(ServerRegistry.class.getClassLoader())
+ .setContextPath("/")
+ .setDeploymentName("AOS Server Registry")
+ .addServlets(
+ Servlets.servlet("ServersInfoServlet", ServerInfoServlet.class)
+ .addMapping("/serverInfo")
+ );
+ DeploymentManager manager = Servlets.defaultContainer().addDeployment(servletBuilder);
+ manager.deploy();
+ HttpHandler servletHandler = manager.start();
+ Undertow server = Undertow.builder()
+ .addHttpListener(PORT, "localhost")
+ .setHandler(servletHandler)
+ .build();
+ server.start();
+ }
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/DataManager.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/DataManager.java
new file mode 100644
index 0000000..10d7ff1
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/DataManager.java
@@ -0,0 +1,45 @@
+package nl.andrewlalis.aos_server_registry.data;
+
+import java.io.IOException;
+import java.io.InputStreamReader;
+import java.io.PrintWriter;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+
+public class DataManager {
+ private static final String JDBC_URL = "jdbc:h2:mem:server_registry;MODE=MySQL";
+
+ private static DataManager instance;
+
+ public static DataManager getInstance() throws SQLException {
+ if (instance == null) {
+ instance = new DataManager();
+ instance.resetDatabase();
+ }
+ return instance;
+ }
+
+ private final Connection connection;
+
+ private DataManager() throws SQLException {
+ this.connection = DriverManager.getConnection(JDBC_URL);
+ }
+
+ public Connection getConnection() {
+ return this.connection;
+ }
+
+ public void resetDatabase() throws SQLException {
+ var in = DataManager.class.getResourceAsStream("/nl/andrewlalis/aos_server_registry/schema.sql");
+ if (in == null) throw new SQLException("Missing schema.sql. Cannot reset database.");
+ try {
+ ScriptRunner runner = new ScriptRunner(this.connection, false, true);
+ runner.setErrorLogWriter(new PrintWriter(System.err));
+ runner.runScript(new InputStreamReader(in));
+ System.out.println("Successfully reset database.");
+ } catch (IOException e) {
+ throw new SQLException(e);
+ }
+ }
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/ScriptRunner.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/ScriptRunner.java
new file mode 100644
index 0000000..ae0ca26
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/ScriptRunner.java
@@ -0,0 +1,318 @@
+package nl.andrewlalis.aos_server_registry.data;
+/*
+ * Slightly modified version of the com.ibatis.common.jdbc.ScriptRunner class
+ * from the iBATIS Apache project. Only removed dependency on Resource class
+ * and a constructor
+ * GPSHansl, 06.08.2015: regex for delimiter, rearrange comment/delimiter detection, remove some ide warnings.
+ */
+
+/*
+ * Copyright 2004 Clinton Begin
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import java.io.*;
+import java.sql.*;
+import java.text.SimpleDateFormat;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+/**
+ * Tool to run database scripts
+ */
+public class ScriptRunner {
+
+ private static final String DEFAULT_DELIMITER = ";";
+ private static final Pattern SOURCE_COMMAND = Pattern.compile("^\\s*SOURCE\\s+(.*?)\\s*$", Pattern.CASE_INSENSITIVE);
+
+ /**
+ * regex to detect delimiter.
+ * ignores spaces, allows delimiter in comment, allows an equals-sign
+ */
+ public static final Pattern delimP = Pattern.compile("^\\s*(--)?\\s*delimiter\\s*=?\\s*([^\\s]+)+\\s*.*$", Pattern.CASE_INSENSITIVE);
+
+ private final Connection connection;
+
+ private final boolean stopOnError;
+ private final boolean autoCommit;
+
+ @SuppressWarnings("UseOfSystemOutOrSystemErr")
+ private PrintWriter logWriter = null;
+ @SuppressWarnings("UseOfSystemOutOrSystemErr")
+ private PrintWriter errorLogWriter = null;
+
+ private String delimiter = DEFAULT_DELIMITER;
+ private boolean fullLineDelimiter = false;
+
+ private String userDirectory = System.getProperty("user.dir");
+
+ /**
+ * Default constructor
+ */
+ public ScriptRunner(Connection connection, boolean autoCommit,
+ boolean stopOnError) {
+ this.connection = connection;
+ this.autoCommit = autoCommit;
+ this.stopOnError = stopOnError;
+ File logFile = new File("create_db.log");
+ File errorLogFile = new File("create_db_error.log");
+ try {
+ if (logFile.exists()) {
+ logWriter = new PrintWriter(new FileWriter(logFile, true));
+ } else {
+ logWriter = new PrintWriter(new FileWriter(logFile, false));
+ }
+ } catch(IOException e){
+ System.err.println("Unable to access or create the db_create log");
+ }
+ try {
+ if (errorLogFile.exists()) {
+ errorLogWriter = new PrintWriter(new FileWriter(errorLogFile, true));
+ } else {
+ errorLogWriter = new PrintWriter(new FileWriter(errorLogFile, false));
+ }
+ } catch(IOException e){
+ System.err.println("Unable to access or create the db_create error log");
+ }
+ String timeStamp = new SimpleDateFormat("dd/mm/yyyy HH:mm:ss").format(new java.util.Date());
+ println("\n-------\n" + timeStamp + "\n-------\n");
+ printlnError("\n-------\n" + timeStamp + "\n-------\n");
+ }
+
+ public void setDelimiter(String delimiter, boolean fullLineDelimiter) {
+ this.delimiter = delimiter;
+ this.fullLineDelimiter = fullLineDelimiter;
+ }
+
+ /**
+ * Setter for logWriter property
+ *
+ * @param logWriter - the new value of the logWriter property
+ */
+ public void setLogWriter(PrintWriter logWriter) {
+ this.logWriter = logWriter;
+ }
+
+ /**
+ * Setter for errorLogWriter property
+ *
+ * @param errorLogWriter - the new value of the errorLogWriter property
+ */
+ public void setErrorLogWriter(PrintWriter errorLogWriter) {
+ this.errorLogWriter = errorLogWriter;
+ }
+
+ /**
+ * Set the current working directory. Source commands will be relative to this.
+ */
+ public void setUserDirectory(String userDirectory) {
+ this.userDirectory = userDirectory;
+ }
+
+ /**
+ * Runs an SQL script (read in using the Reader parameter)
+ *
+ * @param filepath - the filepath of the script to run. May be relative to the userDirectory.
+ */
+ public void runScript(String filepath) throws IOException, SQLException {
+ File file = new File(userDirectory, filepath);
+ this.runScript(new BufferedReader(new FileReader(file)));
+ }
+
+ /**
+ * Runs an SQL script (read in using the Reader parameter)
+ *
+ * @param reader - the source of the script
+ */
+ public void runScript(Reader reader) throws IOException, SQLException {
+ try {
+ boolean originalAutoCommit = connection.getAutoCommit();
+ try {
+ if (originalAutoCommit != this.autoCommit) {
+ connection.setAutoCommit(this.autoCommit);
+ }
+ runScript(connection, reader);
+ } finally {
+ connection.setAutoCommit(originalAutoCommit);
+ }
+ } catch (IOException | SQLException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new RuntimeException("Error running script. Cause: " + e, e);
+ }
+ }
+
+ /**
+ * Runs an SQL script (read in using the Reader parameter) using the
+ * connection passed in
+ *
+ * @param conn - the connection to use for the script
+ * @param reader - the source of the script
+ * @throws SQLException if any SQL errors occur
+ * @throws IOException if there is an error reading from the Reader
+ */
+ private void runScript(Connection conn, Reader reader) throws IOException,
+ SQLException {
+ StringBuffer command = null;
+ try {
+ LineNumberReader lineReader = new LineNumberReader(reader);
+ String line;
+ while ((line = lineReader.readLine()) != null) {
+ if (command == null) {
+ command = new StringBuffer();
+ }
+ String trimmedLine = line.trim();
+ final Matcher delimMatch = delimP.matcher(trimmedLine);
+ if (trimmedLine.length() < 1
+ || trimmedLine.startsWith("//")) {
+ // Do nothing
+ } else if (delimMatch.matches()) {
+ setDelimiter(delimMatch.group(2), false);
+ } else if (trimmedLine.startsWith("--")) {
+ println(trimmedLine);
+ } else if (trimmedLine.length() < 1
+ || trimmedLine.startsWith("--")) {
+ // Do nothing
+ } else if (!fullLineDelimiter
+ && trimmedLine.endsWith(getDelimiter())
+ || fullLineDelimiter
+ && trimmedLine.equals(getDelimiter())) {
+ command.append(line.substring(0, line
+ .lastIndexOf(getDelimiter())));
+ command.append(" ");
+ this.execCommand(conn, command, lineReader);
+ command = null;
+ } else {
+ command.append(line);
+ command.append("\n");
+ }
+ }
+ if (command != null) {
+ this.execCommand(conn, command, lineReader);
+ }
+ if (!autoCommit) {
+ conn.commit();
+ }
+ }
+ catch (IOException e) {
+ throw new IOException(String.format("Error executing '%s': %s", command, e.getMessage()), e);
+ } finally {
+ conn.rollback();
+ flush();
+ }
+ }
+
+ private void execCommand(Connection conn, StringBuffer command,
+ LineNumberReader lineReader) throws IOException, SQLException {
+
+ if (command.length() == 0) {
+ return;
+ }
+
+ Matcher sourceCommandMatcher = SOURCE_COMMAND.matcher(command);
+ if (sourceCommandMatcher.matches()) {
+ this.runScriptFile(conn, sourceCommandMatcher.group(1));
+ return;
+ }
+
+ this.execSqlCommand(conn, command, lineReader);
+ }
+
+ private void runScriptFile(Connection conn, String filepath) throws IOException, SQLException {
+ File file = new File(userDirectory, filepath);
+ this.runScript(conn, new BufferedReader(new FileReader(file)));
+ }
+
+ private void execSqlCommand(Connection conn, StringBuffer command,
+ LineNumberReader lineReader) throws SQLException {
+
+ Statement statement = conn.createStatement();
+
+ println(command);
+
+ boolean hasResults = false;
+ try {
+ hasResults = statement.execute(command.toString());
+ } catch (SQLException e) {
+ final String errText = String.format("Error executing '%s' (line %d): %s",
+ command, lineReader.getLineNumber(), e.getMessage());
+ printlnError(errText);
+ System.err.println(errText);
+ if (stopOnError) {
+ throw new SQLException(errText, e);
+ }
+ }
+
+ if (autoCommit && !conn.getAutoCommit()) {
+ conn.commit();
+ }
+
+ ResultSet rs = statement.getResultSet();
+ if (hasResults && rs != null) {
+ ResultSetMetaData md = rs.getMetaData();
+ int cols = md.getColumnCount();
+ for (int i = 1; i <= cols; i++) {
+ String name = md.getColumnLabel(i);
+ print(name + "\t");
+ }
+ println("");
+ while (rs.next()) {
+ for (int i = 1; i <= cols; i++) {
+ String value = rs.getString(i);
+ print(value + "\t");
+ }
+ println("");
+ }
+ }
+
+ try {
+ statement.close();
+ } catch (Exception e) {
+ // Ignore to workaround a bug in Jakarta DBCP
+ }
+ }
+
+ private String getDelimiter() {
+ return delimiter;
+ }
+
+ @SuppressWarnings("UseOfSystemOutOrSystemErr")
+
+ private void print(Object o) {
+ if (logWriter != null) {
+ logWriter.print(o);
+ }
+ }
+
+ private void println(Object o) {
+ if (logWriter != null) {
+ logWriter.println(o);
+ }
+ }
+
+ private void printlnError(Object o) {
+ if (errorLogWriter != null) {
+ errorLogWriter.println(o);
+ }
+ }
+
+ private void flush() {
+ if (logWriter != null) {
+ logWriter.flush();
+ }
+ if (errorLogWriter != null) {
+ errorLogWriter.flush();
+ }
+ }
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/ServerDataPruner.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/ServerDataPruner.java
new file mode 100644
index 0000000..8800a96
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/ServerDataPruner.java
@@ -0,0 +1,31 @@
+package nl.andrewlalis.aos_server_registry.data;
+
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+
+/**
+ * Scheduled task that runs once in a while and removes servers from the
+ * registry which have not been updated in a while.
+ */
+public class ServerDataPruner implements Runnable {
+ public static final int INTERVAL_MINUTES = 5;
+ @Override
+ public void run() {
+ try {
+ var con = DataManager.getInstance().getConnection();
+ String sql = """
+ DELETE FROM servers
+ WHERE DATEDIFF('MINUTE', servers.updated_at, CURRENT_TIMESTAMP(0)) > ?
+ """;
+ PreparedStatement stmt = con.prepareStatement(sql);
+ stmt.setInt(1, INTERVAL_MINUTES);
+ int rowCount = stmt.executeUpdate();
+ stmt.close();
+ if (rowCount > 0) {
+ System.out.println("Removed " + rowCount + " servers from registry due to inactivity.");
+ }
+ } catch (SQLException e) {
+ e.printStackTrace();
+ }
+ }
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/Transaction.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/Transaction.java
new file mode 100644
index 0000000..2c72773
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/data/Transaction.java
@@ -0,0 +1,8 @@
+package nl.andrewlalis.aos_server_registry.data;
+
+import java.sql.Connection;
+import java.sql.SQLException;
+
+public interface Transaction {
+ void execute(Connection con) throws SQLException;
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/Page.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/Page.java
new file mode 100644
index 0000000..8337ad3
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/Page.java
@@ -0,0 +1,45 @@
+package nl.andrewlalis.aos_server_registry.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;
+
+ public Page(List contents, int currentPage, int pageSize) {
+ this.contents = contents;
+ this.elementCount = contents.size();
+ this.pageSize = pageSize;
+ this.currentPage = currentPage;
+ this.firstPage = currentPage == 0;
+ this.lastPage = this.elementCount < this.pageSize;
+ }
+
+ 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;
+ }
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/ServerInfoServlet.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/ServerInfoServlet.java
new file mode 100644
index 0000000..a4bd6a2
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/ServerInfoServlet.java
@@ -0,0 +1,160 @@
+package nl.andrewlalis.aos_server_registry.servlet;
+
+import nl.andrewlalis.aos_server_registry.data.DataManager;
+import nl.andrewlalis.aos_server_registry.servlet.dto.ServerInfoResponse;
+import nl.andrewlalis.aos_server_registry.servlet.dto.ServerInfoUpdate;
+import nl.andrewlalis.aos_server_registry.servlet.dto.ServerStatusUpdate;
+import nl.andrewlalis.aos_server_registry.util.Requests;
+import nl.andrewlalis.aos_server_registry.util.Responses;
+
+import javax.servlet.ServletException;
+import javax.servlet.http.HttpServlet;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import java.io.IOException;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+public class ServerInfoServlet extends HttpServlet {
+ @Override
+ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+ int page = Requests.getIntParam(req, "page", 0, i -> i >= 0);
+ int size = Requests.getIntParam(req, "size", 20, i -> i >= 5 && i <= 50);
+ String searchQuery = Requests.getStringParam(req, "q", null, s -> !s.isBlank());
+ String order = Requests.getStringParam(req, "order", "name", s -> !s.isBlank() && (
+ s.equalsIgnoreCase("name") ||
+ s.equalsIgnoreCase("address") ||
+ s.equalsIgnoreCase("location") ||
+ s.equalsIgnoreCase("max_players") ||
+ s.equalsIgnoreCase("current_players")
+ ));
+ String orderDir = Requests.getStringParam(req, "dir", "ASC", s -> s.equalsIgnoreCase("ASC") || s.equalsIgnoreCase("DESC"));
+ try {
+ var results = this.getData(size, page, searchQuery, order, orderDir);
+ Responses.ok(resp, new Page<>(results, page, size));
+ } catch (SQLException t) {
+ t.printStackTrace();
+ Responses.internalServerError(resp, "Database error.");
+ }
+ }
+
+ @Override
+ protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+ var info = Requests.getBody(req, ServerInfoUpdate.class);
+ try {
+ this.saveNewServer(info);
+ Responses.ok(resp, Map.of("message", "Server info saved."));
+ } catch (SQLException e) {
+ e.printStackTrace();
+ Responses.internalServerError(resp, "Database error.");
+ }
+ }
+
+ @Override
+ protected void doPut(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
+ var status = Requests.getBody(req, ServerStatusUpdate.class);
+ try {
+ this.updateServerStatus(status);
+ Responses.ok(resp, Map.of("message", "Server status updated."));
+ } catch (SQLException e) {
+ e.printStackTrace();
+ Responses.internalServerError(resp, "Database error.");
+ }
+ }
+
+ private List getData(int size, int page, String searchQuery, String order, String orderDir) throws SQLException {
+ final List results = new ArrayList<>(20);
+ var con = DataManager.getInstance().getConnection();
+ String selectQuery = """
+ SELECT name, address, updated_at, description, location, max_players, current_players
+ FROM servers
+ //CONDITIONS
+ ORDER BY name
+ LIMIT ?
+ OFFSET ?
+ """;
+ selectQuery = selectQuery.replace("ORDER BY name", "ORDER BY " + order + " " + orderDir);
+ if (searchQuery != null && !searchQuery.isBlank()) {
+ selectQuery = selectQuery.replace("//CONDITIONS", "WHERE UPPER(name) LIKE ?");
+ }
+ PreparedStatement stmt = con.prepareStatement(selectQuery);
+ int index = 1;
+ if (searchQuery != null && !searchQuery.isBlank()) {
+ stmt.setString(index++, "%" + searchQuery.toUpperCase() + "%");
+ }
+ stmt.setInt(index++, size);
+ stmt.setInt(index, page * size);
+ ResultSet rs = stmt.executeQuery();
+ while (rs.next()) {
+ results.add(new ServerInfoResponse(
+ rs.getString(1),
+ rs.getString(2),
+ rs.getTimestamp(3).toInstant().atOffset(ZoneOffset.UTC).toString(),
+ rs.getString(4),
+ rs.getString(5),
+ rs.getInt(6),
+ rs.getInt(7)
+ ));
+ }
+ stmt.close();
+ return results;
+ }
+
+ private void saveNewServer(ServerInfoUpdate info) throws SQLException {
+ var con = DataManager.getInstance().getConnection();
+ PreparedStatement stmt = con.prepareStatement("SELECT name, address FROM servers WHERE name = ? AND address = ?");
+ stmt.setString(1, info.name());
+ stmt.setString(2, info.address());
+ ResultSet rs = stmt.executeQuery();
+ boolean exists = rs.next();
+ stmt.close();
+ if (!exists) {
+ PreparedStatement createStmt = con.prepareStatement("""
+ INSERT INTO servers (name, address, description, location, max_players, current_players)
+ VALUES (?, ?, ?, ?, ?, ?);
+ """);
+ createStmt.setString(1, info.name());
+ createStmt.setString(2, info.address());
+ createStmt.setString(3, info.description());
+ createStmt.setString(4, info.location());
+ createStmt.setInt(5, info.maxPlayers());
+ createStmt.setInt(6, info.currentPlayers());
+ int rowCount = createStmt.executeUpdate();
+ createStmt.close();
+ if (rowCount != 1) throw new SQLException("Could not insert new server.");
+ } else {
+ PreparedStatement updateStmt = con.prepareStatement("""
+ UPDATE servers SET description = ?, location = ?, max_players = ?, current_players = ?
+ WHERE name = ? AND address = ?;
+ """);
+ updateStmt.setString(1, info.description());
+ updateStmt.setString(2, info.location());
+ updateStmt.setInt(3, info.maxPlayers());
+ updateStmt.setInt(4, info.currentPlayers());
+ updateStmt.setString(5, info.name());
+ updateStmt.setString(6, info.address());
+ int rowCount = updateStmt.executeUpdate();
+ updateStmt.close();
+ if (rowCount != 1) throw new SQLException("Could not update server.");
+ }
+ }
+
+ private void updateServerStatus(ServerStatusUpdate status) throws SQLException {
+ var con = DataManager.getInstance().getConnection();
+ PreparedStatement stmt = con.prepareStatement("""
+ UPDATE servers SET current_players = ?
+ WHERE name = ? AND address = ?
+ """);
+ stmt.setInt(1, status.currentPlayers());
+ stmt.setString(2, status.name());
+ stmt.setString(3, status.address());
+ int rowCount = stmt.executeUpdate();
+ stmt.close();
+ if (rowCount != 1) throw new SQLException("Could not update server status.");
+ }
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerInfoResponse.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerInfoResponse.java
new file mode 100644
index 0000000..eb27412
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerInfoResponse.java
@@ -0,0 +1,11 @@
+package nl.andrewlalis.aos_server_registry.servlet.dto;
+
+public record ServerInfoResponse(
+ String name,
+ String address,
+ String updatedAt,
+ String description,
+ String location,
+ int maxPlayers,
+ int currentPlayers
+) {}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerInfoUpdate.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerInfoUpdate.java
new file mode 100644
index 0000000..7dce69f
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerInfoUpdate.java
@@ -0,0 +1,10 @@
+package nl.andrewlalis.aos_server_registry.servlet.dto;
+
+public record ServerInfoUpdate (
+ String name,
+ String address,
+ String description,
+ String location,
+ int maxPlayers,
+ int currentPlayers
+) {}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerStatusUpdate.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerStatusUpdate.java
new file mode 100644
index 0000000..41d3f6f
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/servlet/dto/ServerStatusUpdate.java
@@ -0,0 +1,7 @@
+package nl.andrewlalis.aos_server_registry.servlet.dto;
+
+public record ServerStatusUpdate (
+ String name,
+ String address,
+ int currentPlayers
+) {}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/util/Requests.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/util/Requests.java
new file mode 100644
index 0000000..ea186b9
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/util/Requests.java
@@ -0,0 +1,35 @@
+package nl.andrewlalis.aos_server_registry.util;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.util.function.Function;
+
+import static nl.andrewlalis.aos_server_registry.ServerRegistry.mapper;
+
+public class Requests {
+ public static T getBody(HttpServletRequest req, Class bodyClass) throws IOException {
+ return mapper.readValue(req.getInputStream(), bodyClass);
+ }
+
+ public static int getIntParam(HttpServletRequest req, String name, int defaultValue, Function validator) {
+ return getParam(req, name, defaultValue, Integer::parseInt, validator);
+ }
+
+ public static String getStringParam(HttpServletRequest req, String name, String defaultValue, Function validator) {
+ return getParam(req, name, defaultValue, s -> s, validator);
+ }
+
+ private static T getParam(HttpServletRequest req, String name, T defaultValue, Function parser, Function validator) {
+ var values = req.getParameterValues(name);
+ if (values == null || values.length == 0) return defaultValue;
+ try {
+ T value = parser.apply(values[0]);
+ if (!validator.apply(value)) {
+ return defaultValue;
+ }
+ return value;
+ } catch (Exception e) {
+ return defaultValue;
+ }
+ }
+}
diff --git a/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/util/Responses.java b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/util/Responses.java
new file mode 100644
index 0000000..cda999a
--- /dev/null
+++ b/server-registry/src/main/java/nl/andrewlalis/aos_server_registry/util/Responses.java
@@ -0,0 +1,47 @@
+package nl.andrewlalis.aos_server_registry.util;
+
+import javax.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+
+import static nl.andrewlalis.aos_server_registry.ServerRegistry.mapper;
+
+/**
+ * Helper class which provides some convenience methods for returning simple
+ * JSON responses.
+ */
+public class Responses {
+ public static void ok(HttpServletResponse resp, Object body) throws IOException {
+ resp.setStatus(HttpServletResponse.SC_OK);
+ resp.setContentType("application/json");
+ mapper.writeValue(resp.getOutputStream(), body);
+ }
+
+ public static void badRequest(HttpServletResponse resp, String msg) throws IOException {
+ respond(resp, HttpServletResponse.SC_BAD_REQUEST, msg);
+ }
+
+ public static void notFound(HttpServletResponse resp) throws IOException {
+ respond(resp, HttpServletResponse.SC_NOT_FOUND, "Not found.");
+ }
+
+ public static void notFound(HttpServletResponse resp, String msg) throws IOException {
+ respond(resp, HttpServletResponse.SC_NOT_FOUND, msg);
+ }
+
+ public static void internalServerError(HttpServletResponse resp) throws IOException {
+ respond(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Internal server error.");
+ }
+
+ public static void internalServerError(HttpServletResponse resp, String msg) throws IOException {
+ respond(resp, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, msg);
+ }
+
+ private static void respond(HttpServletResponse resp, int status, String msg) throws IOException {
+ resp.setStatus(status);
+ resp.setContentType("application/json");
+ mapper.writeValue(resp.getOutputStream(), msg);
+ }
+
+ private static record ResponseBody(String message) {}
+}
diff --git a/server-registry/src/main/resources/nl/andrewlalis/aos_server_registry/schema.sql b/server-registry/src/main/resources/nl/andrewlalis/aos_server_registry/schema.sql
new file mode 100644
index 0000000..5406d57
--- /dev/null
+++ b/server-registry/src/main/resources/nl/andrewlalis/aos_server_registry/schema.sql
@@ -0,0 +1,18 @@
+SET MODE MySQL;
+
+CREATE TABLE servers (
+ name VARCHAR(64) NOT NULL,
+ address VARCHAR(255) NOT NULL,
+ created_at TIMESTAMP(0) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP(0),
+ updated_at TIMESTAMP(0) WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP(0) ON UPDATE CURRENT_TIMESTAMP(0),
+ description VARCHAR(1024),
+ location VARCHAR(64),
+
+ max_players INTEGER NOT NULL,
+ current_players INTEGER NOT NULL,
+
+ PRIMARY KEY (name, address),
+ CHECK (max_players > 0 AND current_players >= 0)
+);
+
+CREATE INDEX server_name_idx ON servers(name);