diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java index 702d1c8..d44df55 100644 --- a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/ServerApp.java @@ -1,14 +1,24 @@ package ch.unibas.dmi.dbis.cs108.casono.server; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserCleanupJob; +import ch.unibas.dmi.dbis.cs108.casono.server.domain.user.UserRegistry; import ch.unibas.dmi.dbis.cs108.casono.server.network.NetworkManager; import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent; import ch.unibas.dmi.dbis.cs108.casono.server.network.events.EventBus; import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager; +import java.time.Duration; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; /** Application class for starting the server. */ public class ServerApp { + public static final int USER_CLEANUP_JOB_DELAY = 0; + public static final int USER_CLEANUP_JOB_PERIOD = 10; + public static final int USER_CLEANUP_JOB_RECONNECT_THRESHOLD = 10; + public static void start(String arg) { int port = Integer.parseInt(arg); @@ -20,6 +30,18 @@ public class ServerApp { eventBus.subscribe( DisconnectEvent.class, event -> sessionManager.removeSession(event.sessionId())); NetworkManager networkManager = new NetworkManager(port, sessionManager, eventBus); + + UserRegistry userRegistry = new UserRegistry(); + eventBus.subscribe( + DisconnectEvent.class, event -> userRegistry.onDisconnect(event.sessionId())); + ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + scheduler.scheduleAtFixedRate( + new UserCleanupJob( + userRegistry, Duration.ofSeconds(USER_CLEANUP_JOB_RECONNECT_THRESHOLD)), + USER_CLEANUP_JOB_DELAY, + USER_CLEANUP_JOB_PERIOD, + TimeUnit.SECONDS); + networkManager.start(); } } diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java new file mode 100644 index 0000000..904112b --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/User.java @@ -0,0 +1,79 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; +import java.time.Instant; +import java.util.Optional; + +/** Represents an authenticated user on the server. */ +public class User { + private final UserId id; + private final String name; + private SessionId sessionId; + private Instant disconnectedAt; + + /** + * Creates a new User with the given ID, name and session. + * + * @param id the unique identifier for this user + * @param name the display name of this user + * @param sessionId the session currently associated with this user + */ + public User(UserId id, String name, SessionId sessionId) { + this.id = id; + this.name = name; + this.sessionId = sessionId; + this.disconnectedAt = null; + } + + /** + * Returns the ID of this user. + * + * @return the user ID + */ + public UserId getId() { + return id; + } + + /** + * Returns the display name of this user. + * + * @return the user name + */ + public String getName() { + return name; + } + + /** + * Returns the session currently associated with this user, if any. + * + * @return an Optional containing the session ID, or empty if disconnected + */ + public Optional getSessionId() { + return Optional.ofNullable(sessionId); + } + + /** + * Returns the time at which this user disconnected, if applicable. + * + * @return an Optional containing the disconnect timestamp, or empty if connected + */ + public Optional getDisconnectedAt() { + return Optional.ofNullable(disconnectedAt); + } + + /** + * Associates this user with a new session, clearing the disconnect timestamp. + * + * @param sessionId the new session ID + */ + public void reassignSession(SessionId sessionId) { + this.sessionId = sessionId; + this.disconnectedAt = null; + } + + /** Marks this user as disconnected by clearing the session and recording the timestamp. */ + public void markDisconnected() { + this.sessionId = null; + this.disconnectedAt = Instant.now(); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserCleanupJob.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserCleanupJob.java new file mode 100644 index 0000000..32cfbb4 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserCleanupJob.java @@ -0,0 +1,44 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; + +import java.time.Duration; +import java.time.Instant; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +/** + * Periodically periodicly run job to remove disconnected users who have exceeded the reconnect + * threshold. + */ +public class UserCleanupJob implements Runnable { + private final Logger logger; + private final UserRegistry registry; + private final Duration reconnectThreshold; + + public UserCleanupJob(UserRegistry registry, Duration reconnectThreshold) { + this.logger = LogManager.getLogger(UserCleanupJob.class); + this.registry = registry; + this.reconnectThreshold = reconnectThreshold; + } + + @Override + public void run() { + logger.debug("Job started."); + Instant threshold = Instant.now().minus(reconnectThreshold); + + for (User user : registry.getAllUsers()) { + if (user.getDisconnectedAt().isEmpty()) { + continue; + } + + Instant disconnectedAt = user.getDisconnectedAt().get(); + if (disconnectedAt.isBefore(threshold)) { + if (registry.removeIfStillDisconnected(user.getId())) { + logger.info( + "Removed expired user {} ({})", user.getName(), user.getId().value()); + } + } + } + + logger.debug("Job finished."); + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactory.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactory.java new file mode 100644 index 0000000..84ab1ce --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserFactory.java @@ -0,0 +1,43 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; + +/** Creates new users, resolving name conflicts automatically. */ +public class UserFactory { + private final UserRegistry registry; + + /** + * Creates a new UserFactory backed by the given registry. + * + * @param registry the registry to register new users to + */ + public UserFactory(UserRegistry registry) { + this.registry = registry; + } + + /** + * Creates and registers a new user with the given name and session. If the name is already + * taken, a numeric suffix is appended and incremented until a free name is found (e.g. + * Lars_001, Lars_002, ...). + * + * @param desiredName the preferred display name + * @param sessionId the session to associate with the new user + * @return the newly created and registered user + */ + public User create(String desiredName, SessionId sessionId) { + var result = registry.registerIfAvailable(desiredName, sessionId); + if (result.isPresent()) { + return result.get(); + } + + int suffix = 1; + while (true) { + String candidate = desiredName + "_" + String.format("%03d", suffix); + result = registry.registerIfAvailable(candidate, sessionId); + if (result.isPresent()) { + return result.get(); + } + suffix++; + } + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserId.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserId.java new file mode 100644 index 0000000..c8e374e --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserId.java @@ -0,0 +1,31 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; + +import java.util.UUID; + +/** Represents a unique identifier for a user. */ +public class UserId { + private final UUID value; + + /** Creates a new UserId with a randomly generated UUID. */ + public UserId() { + this.value = UUID.randomUUID(); + } + + /** + * Creates a new UserId with the specified UUID. + * + * @param value the UUID to use for this UserId + */ + public UserId(UUID value) { + this.value = value; + } + + /** + * Returns the UUID value of this UserId. + * + * @return the UUID value + */ + public UUID value() { + return value; + } +} diff --git a/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserRegistry.java b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserRegistry.java new file mode 100644 index 0000000..6a60877 --- /dev/null +++ b/src/main/java/ch/unibas/dmi/dbis/cs108/casono/server/domain/user/UserRegistry.java @@ -0,0 +1,107 @@ +package ch.unibas.dmi.dbis.cs108.casono.server.domain.user; + +import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionId; +import java.util.Collection; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** Manages all active users on the server. */ +public class UserRegistry { + private final ConcurrentHashMap byId = new ConcurrentHashMap<>(); + private final ConcurrentHashMap byName = new ConcurrentHashMap<>(); + private final ConcurrentHashMap bySessionId = new ConcurrentHashMap<>(); + + /** + * Attempts to register a user under the given name atomically. Returns the registered user, or + * empty if the name is already taken. + * + * @param name the desired display name + * @param sessionId the session to associate with the new user + * @return an Optional containing the new user, or empty if the name was taken + */ + public synchronized Optional registerIfAvailable(String name, SessionId sessionId) { + if (byName.containsKey(name)) { + return Optional.empty(); + } + + User user = new User(new UserId(), name, sessionId); + byId.put(user.getId(), user); + byName.put(user.getName(), user); + bySessionId.put(sessionId, user); + return Optional.of(user); + } + + /** + * Removes the user with the given ID, but only if they are still disconnected. This prevents + * removing a user who has reconnected between the cleanup job's check and its removal call. + * + * @param userId the ID of the user to remove + */ + public synchronized boolean removeIfStillDisconnected(UserId userId) { + User user = byId.get(userId); + if (user == null) { + return false; + } + + if (user.getSessionId().isPresent()) { + return false; + } + + byId.remove(user.getId()); + byName.remove(user.getName()); + return true; + } + + /** + * Marks the user associated with the given session as disconnected, clearing the session + * association and recording the disconnect timestamp. + * + * @param sessionId the session ID of the disconnected client + */ + // TODO: Add to EventRegistry with DisconnectEvent + public synchronized void onDisconnect(SessionId sessionId) { + User user = bySessionId.remove(sessionId); + if (user == null) { + return; + } + + user.markDisconnected(); + } + + /** + * Reassociates a user with a new session, effectively restoring them after a reconnect. + * + * @param userId the ID of the user to reconnect + * @param sessionId the new session ID + * @return an Optional containing the user, or empty if the user was not found + */ + public synchronized Optional reassignSession(UserId userId, SessionId sessionId) { + User user = byId.get(userId); + if (user == null) { + return Optional.empty(); + } + + user.reassignSession(sessionId); + bySessionId.put(sessionId, user); + return Optional.of(user); + } + + /** + * Looks up a user by their session ID. + * + * @param sessionId the session ID to look up + * @return an Optional containing the user, or empty if no user is associated with this session + */ + public Optional findBySessionId(SessionId sessionId) { + return Optional.ofNullable(bySessionId.get(sessionId)); + } + + /** + * Returns all currently registered users. + * + * @return a collection of all users + */ + public Collection getAllUsers() { + return byId.values(); + } +}