Merge branch 'feat/user-serverside' into 'main'
Add UserRegistry to store all existing users and UserCleanupJob to periodicly remove expired user See merge request cs108-fs26/Gruppe-13!33
This commit was merged in pull request #189.
This commit is contained in:
@@ -1,14 +1,24 @@
|
|||||||
package ch.unibas.dmi.dbis.cs108.casono.server;
|
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.NetworkManager;
|
||||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.events.DisconnectEvent;
|
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.events.EventBus;
|
||||||
import ch.unibas.dmi.dbis.cs108.casono.server.network.sessions.SessionManager;
|
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.LogManager;
|
||||||
import org.apache.logging.log4j.Logger;
|
import org.apache.logging.log4j.Logger;
|
||||||
|
|
||||||
/** Application class for starting the server. */
|
/** Application class for starting the server. */
|
||||||
public class ServerApp {
|
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) {
|
public static void start(String arg) {
|
||||||
int port = Integer.parseInt(arg);
|
int port = Integer.parseInt(arg);
|
||||||
|
|
||||||
@@ -20,6 +30,18 @@ public class ServerApp {
|
|||||||
eventBus.subscribe(
|
eventBus.subscribe(
|
||||||
DisconnectEvent.class, event -> sessionManager.removeSession(event.sessionId()));
|
DisconnectEvent.class, event -> sessionManager.removeSession(event.sessionId()));
|
||||||
NetworkManager networkManager = new NetworkManager(port, sessionManager, eventBus);
|
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();
|
networkManager.start();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<SessionId> 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<Instant> 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<UserId, User> byId = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<String, User> byName = new ConcurrentHashMap<>();
|
||||||
|
private final ConcurrentHashMap<SessionId, User> 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<User> 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<User> 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<User> findBySessionId(SessionId sessionId) {
|
||||||
|
return Optional.ofNullable(bySessionId.get(sessionId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all currently registered users.
|
||||||
|
*
|
||||||
|
* @return a collection of all users
|
||||||
|
*/
|
||||||
|
public Collection<User> getAllUsers() {
|
||||||
|
return byId.values();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user