From ed144fdd65ed66c4dd6f40fe96b1ab56a7fcbdc9 Mon Sep 17 00:00:00 2001 From: Sathvik Rao Poladi <36164509+Sathvik-Rao@users.noreply.github.com> Date: Sat, 17 Oct 2026 00:54:00 -0500 Subject: [PATCH 01/28] stability fixes, admin UI overhaul, captcha tuning Server - Fix NPE in CustomAuthenticationSuccessHandler / setLoginDetails when ipDetails is null (first login from new IP). - New endpoint /admin/online-users plus OnlineUsersService that tracks live WebSocket users in both P2S (STOMP event listener) and P2P (P2PWebSocketHandler.getOnlineUsers) modes. - Add CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC (default -1 = off): per-session token-bucket rate limiter on /p2psignaling messages. - Add CC_CAPTCHA_DIFFICULTY_PERCENT (default 100 = current behavior): scale noise lines, distortion, and background from 0..100. - Advance page: searchable, sortable (per column), paginated tables for user details, online users, and BFA tracker. - Admin Panel (index.html): search box, sortable headers, pager for the users table. - Bump server version to 3.3.0. - added actual endpoint to sort columns and ui update in server to add pagination and column resize horizontally. Desktop (Python) - Frame.unmarshall_single: tolerate malformed STOMP frames (return None instead of crashing the receive thread). - request_manager.login: clean error if the login page has no CSRF input (e.g. wrong server URL). - STOMP and P2P signaling reconnect: exponential backoff + jitter capped at 120s, reset on success and manual reconnect. - P2P fragment reassembly: drop partial buffers older than 60s to avoid memory leak when a peer drops the final fragment. - Single-instance lock: write PID to the lock file, sweep stale lock files on startup, register atexit + signal handlers for cleanup. - Tray (GUI and CLI): read-only "Version v3.3.0" entry. - Login (GUI and CLI): drop "(optional)" suffix from SSL CA bundle label. - Bump desktop version to 3.3.0. Mobile (React Native + Kotlin) - Foreground service: 5s timeout around stompClient.deactivate() so a stuck close cannot hang the service. - P2P fragment reassembly: TTL on partial buffers (60s) matching desktop. - Small "v3.3.0" version line under the footer on both screens. - Bump app version to 3.3.0. --- ClipCascade_Desktop/src/cli/login.py | 2 +- ClipCascade_Desktop/src/cli/tray.py | 2 + ClipCascade_Desktop/src/core/application.py | 86 ++- ClipCascade_Desktop/src/core/constants.py | 17 +- ClipCascade_Desktop/src/gui/login.py | 2 +- ClipCascade_Desktop/src/gui/tray.py | 2 + ClipCascade_Desktop/src/p2p/p2p_manager.py | 69 +- ClipCascade_Desktop/src/stomp_ws/client.py | 11 +- ClipCascade_Desktop/src/stomp_ws/frame.py | 42 +- .../src/stomp_ws/stomp_manager.py | 32 +- .../src/utils/request_manager.py | 10 +- ClipCascade_Mobile/src/App.js | 11 +- .../src/StartForegroundService.js | 51 +- ClipCascade_Server/ClipCascade_Backend/mvnw | 0 .../config/ClipCascadeProperties.java | 35 + .../config/P2PWebSocketHandler.java | 71 ++ .../constants/ServerConstants.java | 2 +- .../controller/ClipCascadeController.java | 84 +++ .../clipcascade/model/BfaSnapshotRow.java | 94 +++ .../acme/clipcascade/model/PageResponse.java | 102 +++ .../clipcascade/model/UserDetailsView.java | 40 ++ .../service/BruteForceProtectionService.java | 79 +++ .../clipcascade/service/CaptchaService.java | 56 +- .../service/FacadeUserService.java | 109 ++- .../service/OnlineUsersService.java | 176 +++++ .../acme/clipcascade/service/UserService.java | 45 ++ .../acme/clipcascade/utils/PagingUtility.java | 100 +++ .../resources/static/assets/css/style.css | 74 +- .../main/resources/static/assets/js/main.js | 253 ++++++- .../src/main/resources/static/index.html | 16 +- .../src/main/resources/templates/advance.html | 663 +++++++++++++----- .../docker-compose-multi-users.yml | 1 + .../docker-compose/docker-compose.yml | 10 + version.json | 12 +- 34 files changed, 2099 insertions(+), 260 deletions(-) mode change 100644 => 100755 ClipCascade_Server/ClipCascade_Backend/mvnw create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/BfaSnapshotRow.java create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/PageResponse.java create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserDetailsView.java create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/OnlineUsersService.java create mode 100644 ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/PagingUtility.java diff --git a/ClipCascade_Desktop/src/cli/login.py b/ClipCascade_Desktop/src/cli/login.py index d11845bd..74231582 100644 --- a/ClipCascade_Desktop/src/cli/login.py +++ b/ClipCascade_Desktop/src/cli/login.py @@ -189,7 +189,7 @@ def mainloop(self): while True: ssl_ca_bundle = ( input( - f"ssl ca bundle path (optional) [{self.config.data.get('ssl_ca_bundle') or ''}]: " + f"ssl ca bundle path [{self.config.data.get('ssl_ca_bundle') or ''}]: " ) or (self.config.data.get("ssl_ca_bundle") or "") ).strip() diff --git a/ClipCascade_Desktop/src/cli/tray.py b/ClipCascade_Desktop/src/cli/tray.py index a282cfbe..f8638a5c 100644 --- a/ClipCascade_Desktop/src/cli/tray.py +++ b/ClipCascade_Desktop/src/cli/tray.py @@ -128,6 +128,8 @@ def create_menu(self, item_: tuple = None): ("💟 Donate", self._open_donate), ("🌐 GitHub", self._open_github), "", # Menu Separator + (f"â„šī¸ Version v{APP_VERSION}", None), # Read-only version display + "", # Menu Separator ("🔒 Logoff and Quit", self._on_logoff), ("❌ Quit", self._on_quit), ] diff --git a/ClipCascade_Desktop/src/core/application.py b/ClipCascade_Desktop/src/core/application.py index 0424ca0d..1eb3bcf7 100644 --- a/ClipCascade_Desktop/src/core/application.py +++ b/ClipCascade_Desktop/src/core/application.py @@ -1,4 +1,6 @@ +import atexit import logging +import signal import sys @@ -97,6 +99,12 @@ def ensure_single_instance(self): ).mainloop() sys.exit(1) + # If a previous run died without cleaning up, the lock *file* may be + # left over even though the kernel released the flock. Remove it if + # the recorded PID is no longer alive — flock would still succeed, + # but this keeps the file tidy and avoids confusing the user. + self._remove_stale_lock_file_if_dead() + # Create the lock file try: self.create_lock_file() @@ -106,12 +114,40 @@ def ensure_single_instance(self): "Another instance of ClipCascade is already running. Do you want to run anyway?", ) if run_anyway == "yes": - os.remove(self.mutex_identifier) + try: + os.remove(self.mutex_identifier) + except OSError: + pass self.create_lock_file() else: self.lock_file = None sys.exit(0) + # Backup cleanup paths for crashes / signals — finally in run() only + # runs on normal completion or handled exceptions. + atexit.register(self._cleanup_lock_file) + self._install_signal_handlers() + + def _remove_stale_lock_file_if_dead(self): + if not os.path.exists(self.mutex_identifier): + return + try: + with open(self.mutex_identifier, "r") as f: + content = f.read().strip() + if not content: + return + pid = int(content) + if pid == os.getpid(): + return + # os.kill(pid, 0) raises if the process is gone or unreachable. + os.kill(pid, 0) + except (ValueError, ProcessLookupError, PermissionError, OSError): + # Stale or unreadable lock file — safe to remove. + try: + os.remove(self.mutex_identifier) + except OSError: + pass + def create_lock_file(self, path=None): if path is None: path = self.mutex_identifier @@ -119,6 +155,44 @@ def create_lock_file(self, path=None): if PLATFORM == MACOS or PLATFORM.startswith(LINUX): self.lock_file = open(path, "w") fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + try: + self.lock_file.write(str(os.getpid())) + self.lock_file.flush() + except Exception: + pass + + def _install_signal_handlers(self): + def _handler(signum, _frame): + self._cleanup_lock_file() + # Re-raise default behavior so the process actually exits. + signal.signal(signum, signal.SIG_DFL) + os.kill(os.getpid(), signum) + + for sig in (signal.SIGTERM, signal.SIGINT, signal.SIGHUP) if hasattr(signal, "SIGHUP") else (signal.SIGTERM, signal.SIGINT): + try: + signal.signal(sig, _handler) + except (ValueError, OSError): + # Some platforms / non-main-thread inits disallow this; skip silently. + pass + + def _cleanup_lock_file(self): + if PLATFORM != MACOS and not PLATFORM.startswith(LINUX): + return + if self.lock_file is None: + return + try: + fcntl.flock(self.lock_file, fcntl.LOCK_UN) + except Exception: + pass + try: + self.lock_file.close() + except Exception: + pass + self.lock_file = None + try: + os.remove(self.mutex_identifier) + except OSError: + pass def authenticate_and_connect(self): # Attempt to connect with existing cookie @@ -301,9 +375,9 @@ def run(self): msg + "\nCheck logs in project directory", msg_type="error" ).mainloop() finally: - self._get_ws_manager().disconnect() + try: + self._get_ws_manager().disconnect() + except Exception: + pass if PLATFORM == MACOS or PLATFORM.startswith(LINUX): - if self.lock_file is not None: - fcntl.flock(self.lock_file, fcntl.LOCK_UN) - self.lock_file.close() - os.remove(self.mutex_identifier) + self._cleanup_lock_file() diff --git a/ClipCascade_Desktop/src/core/constants.py b/ClipCascade_Desktop/src/core/constants.py index 78c357a3..8b2986ec 100644 --- a/ClipCascade_Desktop/src/core/constants.py +++ b/ClipCascade_Desktop/src/core/constants.py @@ -165,18 +165,21 @@ def _strip_linux_cli_overrides(argv): # App version if PLATFORM == WINDOWS: - APP_VERSION = "3.2.0" + APP_VERSION = "3.3.0" elif PLATFORM == MACOS: - APP_VERSION = "3.2.0" + APP_VERSION = "3.3.0" elif PLATFORM.startswith(LINUX): if XMODE: - APP_VERSION = "3.2.0" # gui version + APP_VERSION = "3.3.0" # gui version else: - APP_VERSION = "3.2.0" # non-gui(cli) version + APP_VERSION = "3.3.0" # non-gui(cli) version # core constants -RECONNECT_WS_TIMER = 10 # seconds +RECONNECT_WS_TIMER = 10 # seconds (base delay; retained for backward-compat) +RECONNECT_WS_MAX_TIMER = 120 # seconds (cap for exponential backoff) +RECONNECT_WS_BACKOFF_MULTIPLIER = 2.0 # doubles each consecutive failure +RECONNECT_WS_JITTER_RATIO = 0.25 # +/-25% jitter to avoid thundering herd WEBSOCKET_TIMEOUT = 3000 # milliseconds # P2P signaling WebSocket keepalive (RFC 6455 ping/pong). @@ -188,6 +191,10 @@ def _strip_linux_cli_overrides(argv): # thread does not stall (which would also block processing ASSIGNED_ID / PEER_LIST). P2P_PC_CLOSE_TIMEOUT_SEC = 5.0 +# Drop partially-received fragment buffers older than this many seconds. Prevents +# memory leak when a sender drops the final fragment mid-transfer. +P2P_FRAGMENT_TTL_SEC = 60 + LOG_FILE_NAME = "clipcascade_log.log" LOG_LEVEL = logging.INFO # Use valid levels: DEBUG, INFO, WARNING, ERROR, CRITICAL DATA_FILE_NAME = "DATA" diff --git a/ClipCascade_Desktop/src/gui/login.py b/ClipCascade_Desktop/src/gui/login.py index 65b03101..96787322 100644 --- a/ClipCascade_Desktop/src/gui/login.py +++ b/ClipCascade_Desktop/src/gui/login.py @@ -404,7 +404,7 @@ def __init__( ) # SSL CA bundle (private / corporate PKI) - ssl_ca_label = ttk.Label(self.extra_frame, text="SSL CA bundle (optional):") + ssl_ca_label = ttk.Label(self.extra_frame, text="SSL CA bundle:") ssl_ca_label.grid(row=7, column=0, padx=(0, 10), pady=5, sticky=tk.W) self.ssl_ca_bundle_entry = ttk.Entry( self.extra_frame, width=50, font=("Helvetica", 13) diff --git a/ClipCascade_Desktop/src/gui/tray.py b/ClipCascade_Desktop/src/gui/tray.py index d16a25a1..898c7973 100644 --- a/ClipCascade_Desktop/src/gui/tray.py +++ b/ClipCascade_Desktop/src/gui/tray.py @@ -150,6 +150,8 @@ def create_menu(self, item_: tuple = None): item("💟 Donate", self._open_donate), item("🌐 GitHub", self._open_github), Menu.SEPARATOR, + item(f"â„šī¸ Version v{APP_VERSION}", None, enabled=False), + Menu.SEPARATOR, item("🔒 Logoff and Quit", self._on_logoff), item("❌ Quit", self._on_quit), ] diff --git a/ClipCascade_Desktop/src/p2p/p2p_manager.py b/ClipCascade_Desktop/src/p2p/p2p_manager.py index da99d71e..0f76b2b4 100644 --- a/ClipCascade_Desktop/src/p2p/p2p_manager.py +++ b/ClipCascade_Desktop/src/p2p/p2p_manager.py @@ -1,5 +1,6 @@ import json import logging +import random import time import websocket import asyncio @@ -44,10 +45,14 @@ def __init__(self, config: Config, is_login_phase=True): self.is_auto_reconnecting = False self._suppress_auto_reconnect_once = False self.is_clipboard_monitoring_on = False + self._reconnect_attempts = 0 # Fragment variables self.sending_fragment_id = "" # The id of the fragment currently being sent - self.receiving_fragments: dict = {} # Mapping: fragmentid:str -> fragment:list[str] + # Mapping: fragment_id:str -> {"started_at": float, "fragments": list[str]} + # The started_at timestamp lets us drop stale partials so a dropped final + # fragment doesn't leak memory forever. + self.receiving_fragments: dict = {} self.sending_fragment_stats: str = None self.receiving_fragment_stats: str = None @@ -206,6 +211,13 @@ def connect(self) -> tuple[bool, str]: pass return False, msg + def _next_reconnect_delay(self) -> float: + attempts = max(0, self._reconnect_attempts) + base = RECONNECT_WS_TIMER * (RECONNECT_WS_BACKOFF_MULTIPLIER ** attempts) + base = min(base, RECONNECT_WS_MAX_TIMER) + jitter = base * RECONNECT_WS_JITTER_RATIO + return max(1.0, base + random.uniform(-jitter, jitter)) + def _on_ws_close(self, ws, *args): self.ws_client = None self.is_connected = False @@ -221,12 +233,26 @@ def _on_ws_close(self, ws, *args): message="Check your internet connection. Retrying...", ) self.first_conn_lost = False - time.sleep(RECONNECT_WS_TIMER) # seconds + delay = self._next_reconnect_delay() + self._reconnect_attempts += 1 + logging.info( + "P2P signaling reconnect attempt %d in %.1fs", + self._reconnect_attempts, + delay, + ) + slept = 0.0 + while slept < delay and not self.disconnected: + step = min(0.5, delay - slept) + time.sleep(step) + slept += step + if self.disconnected: + return self.connect() def manual_reconnect(self): if not self.is_auto_reconnecting: self.disconnected = False + self._reconnect_attempts = 0 self._suppress_auto_reconnect_once = self.ws_client is not None self.ws_close() self.is_connected = False @@ -294,6 +320,7 @@ def _on_ws_open(self, ws, *args): self.is_connected = True self.is_auto_reconnecting = False + self._reconnect_attempts = 0 self.loop.call_soon_threadsafe(self._restart_dc_heartbeat) if not self.first_conn_lost: self.first_conn_lost = True @@ -769,6 +796,26 @@ def reset_receiving_fragments(self): self.receiving_fragments = {} self.receiving_fragment_stats = None + def _prune_stale_receiving_fragments(self) -> None: + """Drop partial fragment buffers older than P2P_FRAGMENT_TTL_SEC. + Why: if a peer disconnects mid-transfer, the final fragment never arrives + and the buffer would leak indefinitely otherwise. + """ + if not self.receiving_fragments: + return + now = time.monotonic() + stale = [ + fid + for fid, entry in self.receiving_fragments.items() + if isinstance(entry, dict) + and (now - entry.get("started_at", now)) > P2P_FRAGMENT_TTL_SEC + ] + for fid in stale: + logging.debug("P2P: dropping stale fragment buffer %s", fid) + self.receiving_fragments.pop(fid, None) + if not self.receiving_fragments: + self.receiving_fragment_stats = None + def _receive(self, frame: any) -> str: try: body = json.loads(frame) @@ -794,14 +841,17 @@ def _receive(self, frame: any) -> str: # Fragmented message handling if metadata is not None and metadata["isFragmented"]: + self._prune_stale_receiving_fragments() self.receiving_fragment_stats = ( f"{metadata['index'] + 1}/{metadata['totalFragments']}" ) if metadata["id"] in self.receiving_fragments: - self.receiving_fragments[metadata["id"]][metadata["index"]] = payload + entry = self.receiving_fragments[metadata["id"]] + entry["fragments"][metadata["index"]] = payload if metadata["index"] == metadata["totalFragments"] - 1: - if all(s != "" for s in self.receiving_fragments[metadata["id"]]): - payload = "".join(self.receiving_fragments[metadata["id"]]) + if all(s != "" for s in entry["fragments"]): + payload = "".join(entry["fragments"]) + self.receiving_fragments.pop(metadata["id"], None) else: self.reset_receiving_fragments() logging.error( @@ -812,8 +862,13 @@ def _receive(self, frame: any) -> str: return else: self.reset_receiving_fragments() - self.receiving_fragments[metadata["id"]] = [""] * metadata["totalFragments"] - self.receiving_fragments[metadata["id"]][metadata["index"]] = payload + self.receiving_fragments[metadata["id"]] = { + "started_at": time.monotonic(), + "fragments": [""] * metadata["totalFragments"], + } + self.receiving_fragments[metadata["id"]]["fragments"][ + metadata["index"] + ] = payload return if self.config.data["cipher_enabled"]: diff --git a/ClipCascade_Desktop/src/stomp_ws/client.py b/ClipCascade_Desktop/src/stomp_ws/client.py index e09f32fe..df56eece 100644 --- a/ClipCascade_Desktop/src/stomp_ws/client.py +++ b/ClipCascade_Desktop/src/stomp_ws/client.py @@ -72,6 +72,10 @@ def _on_message(self, ws_app, message, *args): logging.debug("\n<<< " + str(message)) frame = Frame.unmarshall_single(message) + if frame is None: + # Malformed frame — log and continue rather than killing the receive loop. + logging.warning("STOMP: dropped malformed/unparseable frame") + return [] _results = [] if frame.command == "CONNECTED": self.connected = True @@ -80,11 +84,14 @@ def _on_message(self, ws_app, message, *args): _results.append(self._connectCallback(frame)) elif frame.command == "MESSAGE": - subscription = frame.headers["subscription"] + subscription = frame.headers.get("subscription") + if subscription is None: + logging.warning("STOMP: MESSAGE frame missing 'subscription' header; dropping") + return [] if subscription in self.subscriptions: onreceive = self.subscriptions[subscription] - messageID = frame.headers["message-id"] + messageID = frame.headers.get("message-id", "") def ack(headers): if headers is None: diff --git a/ClipCascade_Desktop/src/stomp_ws/frame.py b/ClipCascade_Desktop/src/stomp_ws/frame.py index 4327558a..0e50e08e 100644 --- a/ClipCascade_Desktop/src/stomp_ws/frame.py +++ b/ClipCascade_Desktop/src/stomp_ws/frame.py @@ -28,23 +28,39 @@ def __str__(self): @staticmethod def unmarshall_single(data): - lines = data.split(Byte["LF"]) + # A single malformed frame should not kill the receive thread. + # Return None so callers can skip the frame without losing the connection. + try: + if data is None: + return None + lines = data.split(Byte["LF"]) + if not lines: + return None - command = lines[0].strip() - headers = {} + command = lines[0].strip() + if not command: + return None + headers = {} - # get all headers - i = 1 - while lines[i] != "": - # get key, value from raw header - (key, value) = lines[i].split(":", 1) - headers[key] = value - i += 1 + # get all headers + i = 1 + while i < len(lines) and lines[i] != "": + # Skip header lines that don't contain a ':' separator instead of crashing. + if ":" in lines[i]: + (key, value) = lines[i].split(":", 1) + headers[key] = value + i += 1 - # set body to None if there is no body - body = None if lines[i + 1] == Byte["NULL"] else lines[i + 1][:-1] + # set body to None if no body line exists or the body marker indicates empty + if i + 1 >= len(lines): + body = None + else: + raw_body = lines[i + 1] + body = None if raw_body == Byte["NULL"] or raw_body == "" else raw_body[:-1] - return Frame(command, headers, body) + return Frame(command, headers, body) + except Exception: + return None @staticmethod def marshall(command, headers, body): diff --git a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py index 48a78c4a..3a835793 100644 --- a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py +++ b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py @@ -1,5 +1,6 @@ import json import logging +import random import time @@ -32,6 +33,7 @@ def __init__(self, config: Config, is_login_phase=True): self.is_connected = False self.disconnected = False self.is_auto_reconnecting = False + self._reconnect_attempts = 0 def set_tray_ref(self, sys_tray: TaskbarPanel): """ @@ -76,6 +78,7 @@ def connect(self) -> tuple[bool, str]: # logging.info("Websocket connected") self.is_connected = True self.is_auto_reconnecting = False + self._reconnect_attempts = 0 if not self.first_conn_lost: self.first_conn_lost = True self.notification_manager.notify( @@ -91,6 +94,18 @@ def connect(self) -> tuple[bool, str]: logging.error(msg) return False, msg + def _next_reconnect_delay(self) -> float: + """ + Exponential backoff with jitter, capped at RECONNECT_WS_MAX_TIMER seconds. + Why: a flat 10s retry hammers the server during outages; backoff + jitter + spreads reconnect storms across many clients (thundering-herd mitigation). + """ + attempts = max(0, self._reconnect_attempts) + base = RECONNECT_WS_TIMER * (RECONNECT_WS_BACKOFF_MULTIPLIER ** attempts) + base = min(base, RECONNECT_WS_MAX_TIMER) + jitter = base * RECONNECT_WS_JITTER_RATIO + return max(1.0, base + random.uniform(-jitter, jitter)) + def _on_close(self): self.is_connected = False # Auto Reconnect @@ -102,7 +117,21 @@ def _on_close(self): message="Check your internet connection. Retrying...", ) self.first_conn_lost = False - time.sleep(RECONNECT_WS_TIMER) # seconds + delay = self._next_reconnect_delay() + self._reconnect_attempts += 1 + logging.info( + "STOMP reconnect attempt %d in %.1fs", + self._reconnect_attempts, + delay, + ) + # Use short sleeps so a disconnect() request can abort the wait promptly. + slept = 0.0 + while slept < delay and not self.disconnected: + step = min(0.5, delay - slept) + time.sleep(step) + slept += step + if self.disconnected: + return self.connect() def send(self, payload: str, payload_type: str = "text"): @@ -143,6 +172,7 @@ def _receive(self, frame: any) -> str: def manual_reconnect(self): if not self.is_auto_reconnecting: self.disconnected = False + self._reconnect_attempts = 0 self.connect() def disconnect(self): diff --git a/ClipCascade_Desktop/src/utils/request_manager.py b/ClipCascade_Desktop/src/utils/request_manager.py index 6918974e..677271af 100644 --- a/ClipCascade_Desktop/src/utils/request_manager.py +++ b/ClipCascade_Desktop/src/utils/request_manager.py @@ -37,7 +37,15 @@ def login(self) -> tuple[bool, str, dict]: return False, msg, None soup = BeautifulSoup(response.text, "html.parser") - csrf_token = soup.find("input", {"name": "_csrf"})["value"] + csrf_input = soup.find("input", {"name": "_csrf"}) + if csrf_input is None or not csrf_input.get("value"): + msg = ( + "Login page did not contain a CSRF token. " + "Verify the server URL points to a ClipCascade server." + ) + logging.error(msg) + return False, msg, None + csrf_token = csrf_input["value"] # Login with the credentials form_data = { diff --git a/ClipCascade_Mobile/src/App.js b/ClipCascade_Mobile/src/App.js index 72aec4ba..7078681d 100644 --- a/ClipCascade_Mobile/src/App.js +++ b/ClipCascade_Mobile/src/App.js @@ -60,7 +60,7 @@ import StartForegroundService from './StartForegroundService'; */ // App version -const APP_VERSION = '3.2.0'; +const APP_VERSION = '3.3.0'; // Main App export default function App() { @@ -1122,6 +1122,7 @@ export default function App() { )} + v{APP_VERSION} )} @@ -1371,6 +1372,7 @@ export default function App() { HOMEPAGE + v{APP_VERSION} )} @@ -1491,4 +1493,11 @@ const styles = StyleSheet.create({ spacing: { marginHorizontal: 12, }, + versionText: { + textAlign: 'center', + fontSize: 11, + color: '#9aa0a6', + marginTop: 4, + marginBottom: 14, + }, }); diff --git a/ClipCascade_Mobile/src/StartForegroundService.js b/ClipCascade_Mobile/src/StartForegroundService.js index c26c2dd6..d2408f4c 100644 --- a/ClipCascade_Mobile/src/StartForegroundService.js +++ b/ClipCascade_Mobile/src/StartForegroundService.js @@ -39,6 +39,8 @@ module.exports = async (inputData = null) => { const RECONNECT_WS_TIMER = 10000; // 10 seconds const HEARTBEAT_INTERVAL = 20000; // 20 seconds const FRAGMENT_SIZE = 15360; // 15 KiB + const FRAGMENT_TTL_MS = 60000; // drop partial fragment buffers older than 60s + const STOMP_DEACTIVATE_TIMEOUT_MS = 5000; // bound the STOMP deactivate() wait // forground service notifee.registerForegroundService(notification => { @@ -598,9 +600,16 @@ module.exports = async (inputData = null) => { stompClient.onWebSocketError = null; stompClient.onWebSocketClose = () => {}; - // Deactivate the stomp connection + // Deactivate the stomp connection, but don't hang here forever: + // some socket states cause deactivate() to never resolve, which + // would prevent the foreground service from stopping. try { - await stompClient.deactivate(); + await Promise.race([ + stompClient.deactivate(), + new Promise(resolve => + setTimeout(resolve, STOMP_DEACTIVATE_TIMEOUT_MS), + ), + ]); } catch (e) { // no-op } @@ -657,6 +666,24 @@ module.exports = async (inputData = null) => { await resetP2PMsg(); }; + // Drop partial fragment buffers older than FRAGMENT_TTL_MS so a peer + // that disconnects mid-transfer can't leak memory forever. + const pruneStaleReceivingFragments = () => { + const now = Date.now(); + let pruned = 0; + for (const fid of Object.keys(receivingFragments)) { + const entry = receivingFragments[fid]; + if (entry && typeof entry.startedAt === 'number' + && (now - entry.startedAt) > FRAGMENT_TTL_MS) { + delete receivingFragments[fid]; + pruned += 1; + } + } + if (pruned > 0 && Object.keys(receivingFragments).length === 0) { + receivingFragmentStats = null; + } + }; + const resetP2PMsg = async () => { p2pMsg = null; await p2pStatusMessageChanged(); @@ -1048,22 +1075,23 @@ module.exports = async (inputData = null) => { // Fragmented message handling if (metadata != null && metadata.isFragmented) { + pruneStaleReceivingFragments(); receivingFragmentStats = `${metadata.index + 1}/${ metadata.totalFragments }`; await p2pStatusMessageChanged(); if (metadata.id in receivingFragments) { - receivingFragments[metadata.id][metadata.index] = cb; + const entry = receivingFragments[metadata.id]; + entry.fragments[metadata.index] = cb; // If this is the last fragment, try to combine if (metadata.index === metadata.totalFragments - 1) { // Check if all fragments are present (none is empty) - if ( - receivingFragments[metadata.id].every(frag => frag !== '') - ) { + if (entry.fragments.every(frag => frag !== '')) { // Join them all together into one payload - cb = receivingFragments[metadata.id].join(''); + cb = entry.fragments.join(''); + delete receivingFragments[metadata.id]; } else { // Missing fragment(s): error out await resetReceivingFragments(); @@ -1078,10 +1106,11 @@ module.exports = async (inputData = null) => { } } else { await resetReceivingFragments(); - receivingFragments[metadata.id] = Array( - metadata.totalFragments, - ).fill(''); - receivingFragments[metadata.id][metadata.index] = cb; + receivingFragments[metadata.id] = { + startedAt: Date.now(), + fragments: Array(metadata.totalFragments).fill(''), + }; + receivingFragments[metadata.id].fragments[metadata.index] = cb; return; } } diff --git a/ClipCascade_Server/ClipCascade_Backend/mvnw b/ClipCascade_Server/ClipCascade_Backend/mvnw old mode 100644 new mode 100755 diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java index 165eb048..e2dbd5db 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java @@ -277,6 +277,24 @@ public class ClipCascadeProperties { @Value("${CC_DONATIONS_ENABLED:false}") private boolean donationsEnabled; + /* + * Maximum number of P2P signaling messages a single WebSocket session can + * send per second. Values <= 0 disable the limiter (default: -1, disabled + * to preserve existing behavior). + * Supported only in P2P mode. + */ + @Value("${CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC:-1}") + private int p2pSignalingRateLimitPerSec; + + /* + * Captcha difficulty as a percentage from 0 (easiest) to 100 (hardest, + * which is the existing behavior). Lower values reduce noise lines and + * skip the distortion (gimpy) effect for better human readability while + * keeping at least the basic challenge. (default: 100) + */ + @Value("${CC_CAPTCHA_DIFFICULTY_PERCENT:100}") + private int captchaDifficultyPercent; + private long getMessageSizeInBytes() { /* * Note: Ensure that the same logic is applied in the activemq.xml file as well. @@ -459,6 +477,21 @@ public boolean getDonationsEnabled() { return donationsEnabled; } + public int getP2pSignalingRateLimitPerSec() { + return p2pSignalingRateLimitPerSec; + } + + public int getCaptchaDifficultyPercent() { + // Clamp to 0..100 so out-of-range env values stay safe. + if (captchaDifficultyPercent < 0) { + return 0; + } + if (captchaDifficultyPercent > 100) { + return 100; + } + return captchaDifficultyPercent; + } + @Override public String toString() { return "{\n" + @@ -492,6 +525,8 @@ public String toString() { ",\n p2pStunUrl='" + getP2pStunUrl() + "'" + ",\n maxWsGlobalConnections='" + getMaxWsGlobalConnections() + "'" + ",\n maxWsConnectionsPerUser='" + getMaxWsConnectionsPerUser() + "'" + + ",\n p2pSignalingRateLimitPerSec='" + getP2pSignalingRateLimitPerSec() + "'" + + ",\n captchaDifficultyPercent='" + getCaptchaDifficultyPercent() + "'" + "\n}"; } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java index 62b9dcaa..31b44a77 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java @@ -40,25 +40,59 @@ public class P2PWebSocketHandler extends AbstractWebSocketHandler { private final Logger logger; private final long MAX_GLOBAL_CONNECTIONS; private final long MAX_CONNECTIONS_PER_USER; + private final int SIGNALING_RATE_LIMIT_PER_SEC; private final Map> sessions = new ConcurrentHashMap<>(); private final Map> sessionsUUID = new ConcurrentHashMap<>(); private final Map userLocks = new ConcurrentHashMap<>(); private final Map lastHeartbeat = new ConcurrentHashMap<>(); private final Map sessionLocks = new ConcurrentHashMap<>(); + // Per-session token bucket for signaling messages. Each entry holds [tokens, lastRefillMs]. + private final Map rateBuckets = new ConcurrentHashMap<>(); private final AtomicLong activeConnections = new AtomicLong(); private final AtomicLong totalConnections = new AtomicLong(); private final AtomicLong totalInboundMessages = new AtomicLong(); private final AtomicLong totalOutboundMessages = new AtomicLong(); + private final AtomicLong totalRateLimitedMessages = new AtomicLong(); public P2PWebSocketHandler(ObjectMapper objectMapper, ClipCascadeProperties clipCascadeProperties) { this.objectMapper = objectMapper; this.MAX_GLOBAL_CONNECTIONS = clipCascadeProperties.getMaxWsGlobalConnections(); this.MAX_CONNECTIONS_PER_USER = clipCascadeProperties.getMaxWsConnectionsPerUser(); + this.SIGNALING_RATE_LIMIT_PER_SEC = clipCascadeProperties.getP2pSignalingRateLimitPerSec(); this.logger = (Logger) LoggerFactory.getLogger(P2PWebSocketHandler.class); } + /** + * Token-bucket rate check. Returns true if the message is allowed, false if + * it should be dropped. Disabled (always allows) when the configured limit + * is <= 0 to keep behavior identical to pre-rate-limit deployments. + * + * Bucket capacity equals the per-second rate, so short bursts are tolerated. + */ + private boolean isWithinRateLimit(String sessionId) { + int ratePerSec = SIGNALING_RATE_LIMIT_PER_SEC; + if (ratePerSec <= 0) { + return true; + } + long now = System.currentTimeMillis(); + double[] bucket = rateBuckets.computeIfAbsent( + sessionId, sid -> new double[] { ratePerSec, now }); + synchronized (bucket) { + double elapsedSec = (now - (long) bucket[1]) / 1000.0; + if (elapsedSec > 0) { + bucket[0] = Math.min(ratePerSec, bucket[0] + elapsedSec * ratePerSec); + bucket[1] = now; + } + if (bucket[0] >= 1.0) { + bucket[0] -= 1.0; + return true; + } + return false; + } + } + @Override public void afterConnectionEstablished(@NonNull WebSocketSession session) throws Exception { incrementCounter(activeConnections); @@ -157,6 +191,7 @@ public void afterConnectionClosed(@NonNull WebSocketSession session, @NonNull Cl lastHeartbeat.remove(session.getId()); sessionLocks.remove(session.getId()); + rateBuckets.remove(session.getId()); // Notify everyone else in that user's room broadcastPeerList(username); @@ -217,6 +252,12 @@ protected void handleTextMessage(@NonNull WebSocketSession session, @NonNull Tex return; } + if (!isWithinRateLimit(session.getId())) { + incrementCounter(totalRateLimitedMessages); + logger.debug("Rate-limited signaling message from session {}", session.getId()); + return; + } + Principal principal = session.getPrincipal(); if (principal == null) { return; @@ -346,6 +387,31 @@ public int getConnectionsForUser(String username) { return userSessions.size(); } + /** + * Snapshot of every currently-connected user and how many active sessions + * they hold. Returned as a fresh map so callers (admin UI) can iterate + * safely. + */ + public Map getOnlineUsers() { + Map snapshot = new HashMap<>(); + for (Map.Entry> entry : sessions.entrySet()) { + Map userSessions = entry.getValue(); + if (userSessions == null) { + continue; + } + int liveCount = 0; + for (WebSocketSession s : userSessions.values()) { + if (s != null && s.isOpen()) { + liveCount++; + } + } + if (liveCount > 0) { + snapshot.put(entry.getKey(), liveCount); + } + } + return snapshot; + } + /** * Acquire a ReentrantLock for a given username from the userLocks map. * If it doesn't exist, create it. This ensures the same username uses the same @@ -375,6 +441,10 @@ public Long getTotalOutboundMessages() { return totalOutboundMessages.get(); } + public Long getTotalRateLimitedMessages() { + return totalRateLimitedMessages.get(); + } + private void incrementCounter(AtomicLong counter) { counter.incrementAndGet(); } @@ -390,6 +460,7 @@ public String toString() { ",\n Total Connections=" + getTotalConnections() + ",\n Total Inbound Messages=" + getTotalInboundMessages() + ",\n Total Outbound Messages=" + getTotalOutboundMessages() + + ",\n Total Rate Limited Messages=" + getTotalRateLimitedMessages() + "\n }\n"; } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java index d517971f..06a91ab4 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java @@ -2,7 +2,7 @@ public class ServerConstants { // App version - public static final String APP_VERSION = "3.1.0"; + public static final String APP_VERSION = "3.3.0"; // Version URL public static final String VERSION_URL = "https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json"; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java index 80c54b45..e5bb3874 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java @@ -35,6 +35,7 @@ import com.acme.clipcascade.service.SessionService; import com.acme.clipcascade.service.UserInfoService; import com.acme.clipcascade.service.UserService; +import com.acme.clipcascade.service.OnlineUsersService; import com.acme.clipcascade.service.WebSocketStatsService; import com.acme.clipcascade.utils.ResponseEntityUtil; import com.acme.clipcascade.utils.TimeUtility; @@ -50,6 +51,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.client.RestTemplate; import org.springframework.web.bind.annotation.PutMapping; @@ -66,6 +68,7 @@ public class ClipCascadeController { private final CaptchaService captchaService; private final BruteForceProtectionService bruteForceProtectionService; private final WebSocketStatsService webSocketStatsService; + private final OnlineUsersService onlineUsersService; private final DonationService donationService; public ClipCascadeController( @@ -78,6 +81,7 @@ public ClipCascadeController( SessionService sessionService, BruteForceProtectionService bruteForceProtectionService, WebSocketStatsService webSocketStatsService, + OnlineUsersService onlineUsersService, DonationService donationService) { this.clipCascadeProperties = clipCascadeProperties; @@ -89,6 +93,7 @@ public ClipCascadeController( this.sessionService = sessionService; this.bruteForceProtectionService = bruteForceProtectionService; this.webSocketStatsService = webSocketStatsService; + this.onlineUsersService = onlineUsersService; this.donationService = donationService; } @@ -358,6 +363,17 @@ public ResponseEntity getWebSocketStats( "Forbidden"); } + @GetMapping("/admin/online-users") + public ResponseEntity getOnlineUsers( + @AuthenticationPrincipal UserPrincipal userPrincipal) { + + return ResponseEntityUtil.conditionalExecuteOrError( + userPrincipal.isAdmin(), + () -> ResponseEntityUtil + .executeWithResponse(() -> onlineUsersService.getOnlineUsers()), + "Forbidden"); + } + @GetMapping("/admin/all-users") public ResponseEntity getAllUsers(@AuthenticationPrincipal UserPrincipal userPrincipal) { @@ -387,6 +403,74 @@ public ResponseEntity getUserDetails(@AuthenticationPrincipal UserPrincipal u "Forbidden"); } + @GetMapping("/admin/user-details/page") + public ResponseEntity getUserDetailsPage( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String sort, + @RequestParam(required = false) String direction, + @RequestParam(required = false) String search) { + + return ResponseEntityUtil.conditionalExecuteOrError( + userPrincipal.isAdmin(), + () -> ResponseEntityUtil.executeWithResponse( + () -> facadeUserService.getUserDetailsPage( + page, size, sort, direction, search)), + "Forbidden"); + } + + @GetMapping("/admin/online-users/page") + public ResponseEntity getOnlineUsersPage( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String sort, + @RequestParam(required = false) String direction, + @RequestParam(required = false) String search) { + + return ResponseEntityUtil.conditionalExecuteOrError( + userPrincipal.isAdmin(), + () -> ResponseEntityUtil.executeWithResponse( + () -> onlineUsersService.getOnlineUsersPage( + page, size, sort, direction, search)), + "Forbidden"); + } + + @GetMapping("/admin/bfa-snapshot/page") + public ResponseEntity getBfaSnapshotPage( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String sort, + @RequestParam(required = false) String direction, + @RequestParam(required = false) String search) { + + return ResponseEntityUtil.conditionalExecuteOrError( + userPrincipal.isAdmin(), + () -> ResponseEntityUtil.executeWithResponse( + () -> bruteForceProtectionService.getTrackerPage( + page, size, sort, direction, search)), + "Forbidden"); + } + + @GetMapping("/admin/users/page") + public ResponseEntity getUsersPage( + @AuthenticationPrincipal UserPrincipal userPrincipal, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "10") int size, + @RequestParam(required = false) String sort, + @RequestParam(required = false) String direction, + @RequestParam(required = false) String search) { + + return ResponseEntityUtil.conditionalExecuteOrError( + userPrincipal.isAdmin(), + () -> ResponseEntityUtil.executeWithResponse( + () -> userService.getUsersPage( + RoleConstants.USER, page, size, sort, direction, search)), + "Forbidden"); + } + @GetMapping("/admin/bfa-snapshot-file") @ResponseBody public byte[] getBfaSnapshotFile( diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/BfaSnapshotRow.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/BfaSnapshotRow.java new file mode 100644 index 00000000..bc118db0 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/BfaSnapshotRow.java @@ -0,0 +1,94 @@ +package com.acme.clipcascade.model; + +/** + * Flattened "username + sourceIp" row derived from the nested BFA tracker map. + * Each entry in UserAccessTracker.ipAccessDetails expands into one row, so + * sort/filter/paginate can run over a single list on the server. + */ +public class BfaSnapshotRow { + + private String username; + private String sourceIp; + private int attempts; + private int lockCount; + private long lockTimeout; + private int uniqueIpCount; + private long userLockTimeout; + + public BfaSnapshotRow() { + } + + public BfaSnapshotRow( + String username, + String sourceIp, + int attempts, + int lockCount, + long lockTimeout, + int uniqueIpCount, + long userLockTimeout) { + + this.username = username; + this.sourceIp = sourceIp; + this.attempts = attempts; + this.lockCount = lockCount; + this.lockTimeout = lockTimeout; + this.uniqueIpCount = uniqueIpCount; + this.userLockTimeout = userLockTimeout; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getSourceIp() { + return sourceIp; + } + + public void setSourceIp(String sourceIp) { + this.sourceIp = sourceIp; + } + + public int getAttempts() { + return attempts; + } + + public void setAttempts(int attempts) { + this.attempts = attempts; + } + + public int getLockCount() { + return lockCount; + } + + public void setLockCount(int lockCount) { + this.lockCount = lockCount; + } + + public long getLockTimeout() { + return lockTimeout; + } + + public void setLockTimeout(long lockTimeout) { + this.lockTimeout = lockTimeout; + } + + public int getUniqueIpCount() { + return uniqueIpCount; + } + + public void setUniqueIpCount(int uniqueIpCount) { + this.uniqueIpCount = uniqueIpCount; + } + + public long getUserLockTimeout() { + return userLockTimeout; + } + + public void setUserLockTimeout(long userLockTimeout) { + this.userLockTimeout = userLockTimeout; + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/PageResponse.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/PageResponse.java new file mode 100644 index 00000000..68a7ad18 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/PageResponse.java @@ -0,0 +1,102 @@ +package com.acme.clipcascade.model; + +import java.util.List; + +public class PageResponse { + + private List content; + private long totalElements; + private int totalPages; + private int page; + private int size; + private String sort; + private String direction; + private String search; + + public PageResponse() { + } + + public PageResponse( + List content, + long totalElements, + int totalPages, + int page, + int size, + String sort, + String direction, + String search) { + + this.content = content; + this.totalElements = totalElements; + this.totalPages = totalPages; + this.page = page; + this.size = size; + this.sort = sort; + this.direction = direction; + this.search = search; + } + + public List getContent() { + return content; + } + + public void setContent(List content) { + this.content = content; + } + + public long getTotalElements() { + return totalElements; + } + + public void setTotalElements(long totalElements) { + this.totalElements = totalElements; + } + + public int getTotalPages() { + return totalPages; + } + + public void setTotalPages(int totalPages) { + this.totalPages = totalPages; + } + + public int getPage() { + return page; + } + + public void setPage(int page) { + this.page = page; + } + + public int getSize() { + return size; + } + + public void setSize(int size) { + this.size = size; + } + + public String getSort() { + return sort; + } + + public void setSort(String sort) { + this.sort = sort; + } + + public String getDirection() { + return direction; + } + + public void setDirection(String direction) { + this.direction = direction; + } + + public String getSearch() { + return search; + } + + public void setSearch(String search) { + this.search = search; + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserDetailsView.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserDetailsView.java new file mode 100644 index 00000000..acbc0585 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserDetailsView.java @@ -0,0 +1,40 @@ +package com.acme.clipcascade.model; + +import com.fasterxml.jackson.annotation.JsonUnwrapped; + +/** + * Flat view of a UserInfo row plus the matching Users.enabled flag. + * Used by the admin user-details listing so the UI can show + * enabled/disabled status without a second request. + */ +public class UserDetailsView { + + @JsonUnwrapped + private UserInfo info; + + private Boolean enabled; + + public UserDetailsView() { + } + + public UserDetailsView(UserInfo info, Boolean enabled) { + this.info = info; + this.enabled = enabled; + } + + public UserInfo getInfo() { + return info; + } + + public void setInfo(UserInfo info) { + this.info = info; + } + + public Boolean getEnabled() { + return enabled; + } + + public void setEnabled(Boolean enabled) { + this.enabled = enabled; + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/BruteForceProtectionService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/BruteForceProtectionService.java index 85a738eb..c0f48ff9 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/BruteForceProtectionService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/BruteForceProtectionService.java @@ -5,12 +5,16 @@ import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; import org.ehcache.Cache; import org.ehcache.CacheManager; @@ -19,10 +23,13 @@ import com.acme.clipcascade.config.ClipCascadeProperties; import com.acme.clipcascade.constants.ServerConstants; +import com.acme.clipcascade.model.BfaSnapshotRow; +import com.acme.clipcascade.model.PageResponse; import com.acme.clipcascade.model.UserAccessTracker; import com.acme.clipcascade.model.IpAttemptDetails; import com.acme.clipcascade.model.Timeout; import com.acme.clipcascade.utils.IpAddressResolver; +import com.acme.clipcascade.utils.PagingUtility; import com.acme.clipcascade.utils.TimeUtility; import com.acme.clipcascade.utils.UserValidator; import com.fasterxml.jackson.core.JsonProcessingException; @@ -247,6 +254,78 @@ public Map getTracker() { return snapshotTracker(); } + private static final Map> BFA_COMPARATORS = Map.of( + "username", Comparator.comparing(BfaSnapshotRow::getUsername, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)), + "sourceIp", Comparator.comparing(BfaSnapshotRow::getSourceIp, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)), + "attempts", Comparator.comparingInt(BfaSnapshotRow::getAttempts), + "lockCount", Comparator.comparingInt(BfaSnapshotRow::getLockCount), + "lockTimeout", Comparator.comparingLong(BfaSnapshotRow::getLockTimeout), + "uniqueIpCount", Comparator.comparingInt(BfaSnapshotRow::getUniqueIpCount), + "userLockTimeout", Comparator.comparingLong(BfaSnapshotRow::getUserLockTimeout)); + + /** + * Paginated/sorted/searched flattened view of the tracker. + * Each (username, ip) pair becomes one row, mirroring the previous + * client-side flattening done by advance.html. + */ + public PageResponse getTrackerPage( + int page, + int size, + String sort, + String direction, + String search) { + + Map snapshot = snapshotTracker(); + List rows = new ArrayList<>(); + for (Map.Entry userEntry : snapshot.entrySet()) { + String username = userEntry.getKey(); + UserAccessTracker tracker = userEntry.getValue(); + if (tracker == null) { + continue; + } + int uniqueIpCount = tracker.getUniqueIpSet() == null + ? 0 + : tracker.getUniqueIpSet().size(); + long userLockTimeout = tracker.getLockTimeout(); + Map ipMap = tracker.getIpAccessDetails(); + if (ipMap == null || ipMap.isEmpty()) { + continue; + } + for (Map.Entry ipEntry : ipMap.entrySet()) { + IpAttemptDetails d = ipEntry.getValue(); + rows.add(new BfaSnapshotRow( + username, + ipEntry.getKey(), + d == null ? 0 : d.getAttempts(), + d == null ? 0 : d.getLockCount(), + d == null ? 0 : d.getLockTimeout(), + uniqueIpCount, + userLockTimeout)); + } + } + + String needle = (search == null) ? "" : search.trim().toLowerCase(); + Predicate filter = needle.isEmpty() + ? null + : r -> PagingUtility.contains(r.getUsername(), needle) + || PagingUtility.contains(r.getSourceIp(), needle); + + String effectiveSort = (sort == null || sort.isEmpty()) ? "username" : sort; + String effectiveDir = (direction == null || direction.isEmpty()) ? "asc" : direction; + + return PagingUtility.apply( + rows, + filter, + BFA_COMPARATORS, + effectiveSort, + effectiveDir, + page, + size, + search); + } + /** * Acquire a ReentrantLock for a given username from the userLocks map. * If it doesn't exist, create it. This ensures the same username uses the same diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/CaptchaService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/CaptchaService.java index 6f0066d9..50b9efae 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/CaptchaService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/CaptchaService.java @@ -33,6 +33,7 @@ public class CaptchaService { private final List BACKGROUNDS; private final List NOISES; private final List GIMPYS; + private final int DIFFICULTY_PERCENT; public CaptchaService(@Autowired ClipCascadeProperties clipCascadeProperties) { @@ -45,6 +46,7 @@ public CaptchaService(@Autowired ClipCascadeProperties clipCascadeProperties) { BACKGROUNDS = bgProds(); NOISES = noiseProds(); GIMPYS = gimpyRends(); + DIFFICULTY_PERCENT = clipCascadeProperties.getCaptchaDifficultyPercent(); } else { RANDOM = null; FONTS = null; @@ -53,6 +55,7 @@ public CaptchaService(@Autowired ClipCascadeProperties clipCascadeProperties) { BACKGROUNDS = null; NOISES = null; GIMPYS = null; + DIFFICULTY_PERCENT = 100; } } @@ -97,16 +100,53 @@ public boolean validateCaptcha(String input, boolean caseSensitive, HttpSession } public Captcha createCaptcha(int width, int height, int sizeOfCaptcha) { - return new Captcha.Builder(width, height) - .addBackground(getRandomBackgroundProducer(BACKGROUNDS)) // background + return createCaptcha(width, height, sizeOfCaptcha, DIFFICULTY_PERCENT); + } + + /** + * Build a captcha whose visual obstacles scale with {@code difficultyPercent} + * (0 = easiest, 100 = current/hardest behavior). The thresholds below map + * the percentage to a small number of discrete steps so the appearance is + * predictable: + * + * >= 75%: complex background + 2 noise lines + gimpy distortion (current default) + * >= 50%: complex background + 1 noise line + gimpy distortion + * >= 25%: complex background + 1 noise line, no distortion + * <25%: flat background, no noise, no distortion (just text + border) + * + * Text and border are always present so the challenge is never trivially + * blank. + */ + public Captcha createCaptcha(int width, int height, int sizeOfCaptcha, int difficultyPercent) { + int d = Math.max(0, Math.min(100, difficultyPercent)); + + BackgroundProducer background = (d >= 25) + ? getRandomBackgroundProducer(BACKGROUNDS) + : new FlatColorBackgroundProducer(); + + int noiseLines; + if (d >= 75) { + noiseLines = 2; + } else if (d >= 25) { + noiseLines = 1; + } else { + noiseLines = 0; + } + + boolean applyGimpy = d >= 50; + + Captcha.Builder builder = new Captcha.Builder(width, height) + .addBackground(background) .addText( new DefaultTextProducer(sizeOfCaptcha, SRC_CHARS), - new DefaultWordRenderer(COLORS, FONTS)) // text, colors, fonts - .addNoise(getRandomNoiseProducer(NOISES)) // lines - .addNoise(getRandomNoiseProducer(NOISES)) // lines - .gimp(getRandomGimpyRenderer(GIMPYS)) // distortion effect - .addBorder() // border - .build(); + new DefaultWordRenderer(COLORS, FONTS)); + for (int i = 0; i < noiseLines; i++) { + builder = builder.addNoise(getRandomNoiseProducer(NOISES)); + } + if (applyGimpy) { + builder = builder.gimp(getRandomGimpyRenderer(GIMPYS)); + } + return builder.addBorder().build(); } // Retrieve the captcha image diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java index b6984355..e9e33c1e 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/FacadeUserService.java @@ -1,6 +1,10 @@ package com.acme.clipcascade.service; +import java.util.Comparator; +import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Predicate; import java.util.stream.Collectors; import org.springframework.stereotype.Service; @@ -8,9 +12,12 @@ import com.acme.clipcascade.config.ClipCascadeProperties; import com.acme.clipcascade.constants.RoleConstants; import com.acme.clipcascade.model.IpAttemptDetails; +import com.acme.clipcascade.model.PageResponse; +import com.acme.clipcascade.model.UserDetailsView; import com.acme.clipcascade.model.UserInfo; import com.acme.clipcascade.model.Users; import com.acme.clipcascade.utils.IpAddressResolver; +import com.acme.clipcascade.utils.PagingUtility; import com.acme.clipcascade.utils.TimeUtility; import com.acme.clipcascade.utils.UserValidator; @@ -132,8 +139,14 @@ public Users updateUserStatus( } public UserInfo setLoginDetails(String username, IpAttemptDetails ipDetails) { - int lockCount = ipDetails.getLockCount(); - if (ipDetails.getAttempts() == 0) { + // ipDetails can be null when this IP has no prior failed attempts on + // record (e.g. first login from a fresh IP, or after the tracker was + // pruned). Treat that case as zero attempts so we still record a clean + // login row instead of NPE'ing. + int attempts = ipDetails == null ? 0 : ipDetails.getAttempts(); + int rawLockCount = ipDetails == null ? 0 : ipDetails.getLockCount(); + int lockCount = rawLockCount; + if (attempts == 0) { lockCount -= 1; } String lockoutTime = TimeUtility.convertSecondsToString( @@ -144,11 +157,99 @@ public UserInfo setLoginDetails(String username, IpAttemptDetails ipDetails) { username, IpAddressResolver.getUserIpAddress(), TimeUtility.getCurrentTimeInSeconds(), - (ipDetails.getAttempts() + (ipDetails.getLockCount() * clipCascadeProperties.getMaxAttemptsPerIp())) - - 1, + (attempts + (rawLockCount * clipCascadeProperties.getMaxAttemptsPerIp())) - 1, lockoutTime); } + private static Comparator byInfo( + Comparator infoComparator) { + + return Comparator.comparing(UserDetailsView::getInfo, + Comparator.nullsLast(infoComparator)); + } + + private static final Map> USER_DETAILS_COMPARATORS = Map.ofEntries( + Map.entry("username", byInfo(Comparator.comparing(UserInfo::getUsername, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("enabled", Comparator.comparing(UserDetailsView::getEnabled, + Comparator.nullsLast(Boolean::compare))), + Map.entry("markedForDeletion", byInfo(Comparator.comparing(UserInfo::isMarkedForDeletion))), + Map.entry("firstSignup", byInfo(Comparator.comparing(UserInfo::getFirstSignup, + Comparator.nullsLast(Long::compare)))), + Map.entry("lastLogin", byInfo(Comparator.comparing(UserInfo::getLastLogin, + Comparator.nullsLast(Long::compare)))), + Map.entry("firstSignupIp", byInfo(Comparator.comparing(UserInfo::getFirstSignupIp, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("lastLoginIp", byInfo(Comparator.comparing(UserInfo::getLastLoginIp, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("failedLoginAttempts", byInfo(Comparator.comparing(UserInfo::getFailedLoginAttempts))), + Map.entry("lockoutTime", byInfo(Comparator.comparing(UserInfo::getLockoutTime, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("passwordChangedAt", byInfo(Comparator.comparing(UserInfo::getPasswordChangedAt, + Comparator.nullsLast(Long::compare)))), + Map.entry("email", byInfo(Comparator.comparing(UserInfo::getEmail, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("otp", byInfo(Comparator.comparing(UserInfo::getOtp, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("phone", byInfo(Comparator.comparing(UserInfo::getPhone, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("firstName", byInfo(Comparator.comparing(UserInfo::getFirstName, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("lastName", byInfo(Comparator.comparing(UserInfo::getLastName, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)))), + Map.entry("notes", byInfo(Comparator.comparing(UserInfo::getNotes, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER))))); + + /** + * Backend-driven, joined view for the admin "User Details" table. + * Combines UserInfo rows with Users.enabled and runs search/sort/paginate + * server-side so the UI just renders one page at a time. + */ + public PageResponse getUserDetailsPage( + int page, + int size, + String sort, + String direction, + String search) { + + List infos = userInfoService.getAllUserDetails(); + Map enabledMap = userService.getAllUsers().stream() + .collect(Collectors.toMap(Users::getUsername, Users::getEnabled, + (a, b) -> a)); + + List views = infos.stream() + .map(info -> new UserDetailsView(info, enabledMap.get(info.getUsername()))) + .collect(Collectors.toList()); + + String needle = (search == null) ? "" : search.trim().toLowerCase(); + Predicate filter = needle.isEmpty() + ? null + : v -> { + UserInfo i = v.getInfo(); + return PagingUtility.contains(i.getUsername(), needle) + || PagingUtility.contains(i.getEmail(), needle) + || PagingUtility.contains(i.getFirstName(), needle) + || PagingUtility.contains(i.getLastName(), needle) + || PagingUtility.contains(i.getPhone(), needle) + || PagingUtility.contains(i.getFirstSignupIp(), needle) + || PagingUtility.contains(i.getLastLoginIp(), needle) + || PagingUtility.contains(i.getNotes(), needle); + }; + + String effectiveSort = (sort == null || sort.isEmpty()) ? "lastLogin" : sort; + String effectiveDir = (direction == null || direction.isEmpty()) ? "desc" : direction; + + return PagingUtility.apply( + views, + filter, + USER_DETAILS_COMPARATORS, + effectiveSort, + effectiveDir, + page, + size, + search); + } + public void deleteInactiveUsers(SessionService sessionService, Set excludedUsers) { if (clipCascadeProperties.getAccountPurgeTimeoutSeconds() < 0) { return; diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/OnlineUsersService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/OnlineUsersService.java new file mode 100644 index 00000000..80dd8722 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/OnlineUsersService.java @@ -0,0 +1,176 @@ +package com.acme.clipcascade.service; + +import java.security.Principal; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import org.springframework.context.event.EventListener; +import org.springframework.messaging.simp.stomp.StompHeaderAccessor; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.messaging.SessionConnectedEvent; +import org.springframework.web.socket.messaging.SessionDisconnectEvent; + +import com.acme.clipcascade.config.ClipCascadeProperties; +import com.acme.clipcascade.config.P2PWebSocketHandler; +import com.acme.clipcascade.model.PageResponse; +import com.acme.clipcascade.utils.PagingUtility; + +import jakarta.annotation.Nullable; + +/** + * Tracks which users currently have open WebSocket connections and how many + * connections each user holds. Works in both modes: + * - P2P: delegates to P2PWebSocketHandler, which owns the live session map. + * - P2S: listens to STOMP SessionConnected/Disconnect events and maintains + * an in-memory counter keyed by authenticated username. + */ +@Service +public class OnlineUsersService { + + private final boolean p2pMode; + private final P2PWebSocketHandler p2pWebSocketHandler; + + // P2S-only state. Keyed by username -> live connection count. + private final Map p2sConnectionCounts = new ConcurrentHashMap<>(); + // sessionId -> username, so the disconnect event can decrement the right entry + // (the disconnect event does not always carry the user Principal). + private final Map p2sSessionToUser = new ConcurrentHashMap<>(); + + public OnlineUsersService( + ClipCascadeProperties clipCascadeProperties, + @Nullable P2PWebSocketHandler p2pWebSocketHandler) { + + this.p2pMode = clipCascadeProperties.isP2pEnabled(); + this.p2pWebSocketHandler = p2pWebSocketHandler; + } + + @EventListener + public void onSessionConnected(SessionConnectedEvent event) { + if (p2pMode) { + return; + } + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + Principal principal = accessor.getUser(); + String sessionId = accessor.getSessionId(); + if (principal == null || sessionId == null) { + return; + } + String username = principal.getName(); + p2sSessionToUser.put(sessionId, username); + p2sConnectionCounts + .computeIfAbsent(username, k -> new AtomicInteger(0)) + .incrementAndGet(); + } + + @EventListener + public void onSessionDisconnect(SessionDisconnectEvent event) { + if (p2pMode) { + return; + } + StompHeaderAccessor accessor = StompHeaderAccessor.wrap(event.getMessage()); + String sessionId = accessor.getSessionId(); + if (sessionId == null) { + return; + } + String username = p2sSessionToUser.remove(sessionId); + if (username == null) { + return; + } + AtomicInteger count = p2sConnectionCounts.get(username); + if (count != null && count.decrementAndGet() <= 0) { + p2sConnectionCounts.remove(username); + } + } + + public Map getOnlineUsers() { + if (p2pMode) { + if (p2pWebSocketHandler == null) { + return new HashMap<>(); + } + return p2pWebSocketHandler.getOnlineUsers(); + } + Map snapshot = new HashMap<>(); + for (Map.Entry entry : p2sConnectionCounts.entrySet()) { + int v = entry.getValue().get(); + if (v > 0) { + snapshot.put(entry.getKey(), v); + } + } + return snapshot; + } + + /** + * Single online-users row, flattened from the username->count map so + * PagingUtility can sort/filter/paginate over a list. Public so the + * controller can render it as JSON without a custom serializer. + */ + public static class OnlineUserRow { + private String username; + private int connections; + + public OnlineUserRow() { + } + + public OnlineUserRow(String username, int connections) { + this.username = username; + this.connections = connections; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public int getConnections() { + return connections; + } + + public void setConnections(int connections) { + this.connections = connections; + } + } + + private static final Map> ONLINE_USERS_COMPARATORS = Map.of( + "username", Comparator.comparing(OnlineUserRow::getUsername, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)), + "connections", Comparator.comparingInt(OnlineUserRow::getConnections)); + + public PageResponse getOnlineUsersPage( + int page, + int size, + String sort, + String direction, + String search) { + + List rows = getOnlineUsers().entrySet().stream() + .map(e -> new OnlineUserRow(e.getKey(), e.getValue() == null ? 0 : e.getValue())) + .collect(Collectors.toList()); + + String needle = (search == null) ? "" : search.trim().toLowerCase(); + Predicate filter = needle.isEmpty() + ? null + : r -> PagingUtility.contains(r.getUsername(), needle); + + String effectiveSort = (sort == null || sort.isEmpty()) ? "connections" : sort; + String effectiveDir = (direction == null || direction.isEmpty()) ? "desc" : direction; + + return PagingUtility.apply( + rows, + filter, + ONLINE_USERS_COMPARATORS, + effectiveSort, + effectiveDir, + page, + size, + search); + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java index 3dbc2e0c..4d83c542 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/UserService.java @@ -1,16 +1,21 @@ package com.acme.clipcascade.service; import java.security.MessageDigest; +import java.util.Comparator; import java.util.List; +import java.util.Map; import java.util.function.Consumer; +import java.util.function.Predicate; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.stereotype.Service; import com.acme.clipcascade.constants.RoleConstants; +import com.acme.clipcascade.model.PageResponse; import com.acme.clipcascade.model.Users; import com.acme.clipcascade.repo.UserRepo; import com.acme.clipcascade.utils.HashingUtility; +import com.acme.clipcascade.utils.PagingUtility; import com.acme.clipcascade.utils.UserValidator; @Service @@ -64,6 +69,46 @@ public List getUsers(String role) { return users; } + private static final Map> USERS_COMPARATORS = Map.of( + "username", Comparator.comparing(Users::getUsername, + Comparator.nullsLast(String.CASE_INSENSITIVE_ORDER)), + "enabled", Comparator.comparing(Users::getEnabled, + Comparator.nullsLast(Boolean::compare))); + + /** + * Server-side paged/sorted/searched view of users with the given role. + * Used by the admin panel; the legacy getUsers(role) endpoint is preserved. + */ + public PageResponse getUsersPage( + String role, + int page, + int size, + String sort, + String direction, + String search) { + + List users = userRepo.findByRole(role); + users.forEach(user -> user.setPassword(null)); + + String needle = (search == null) ? "" : search.trim().toLowerCase(); + Predicate filter = needle.isEmpty() + ? null + : u -> PagingUtility.contains(u.getUsername(), needle); + + String effectiveSort = (sort == null || sort.isEmpty()) ? "username" : sort; + String effectiveDir = (direction == null || direction.isEmpty()) ? "asc" : direction; + + return PagingUtility.apply( + users, + filter, + USERS_COMPARATORS, + effectiveSort, + effectiveDir, + page, + size, + search); + } + public long countUsers() { return userRepo.countByRole(RoleConstants.USER); } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/PagingUtility.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/PagingUtility.java new file mode 100644 index 00000000..ba43af39 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/PagingUtility.java @@ -0,0 +1,100 @@ +package com.acme.clipcascade.utils; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.function.Predicate; +import java.util.stream.Collectors; + +import com.acme.clipcascade.model.PageResponse; + +/** + * Generic in-memory filter + sort + paginate for admin listings. + * Used by services whose data isn't backed by Spring Data (online users, + * BFA tracker, etc.) and for joined views that can't be paged at the DB + * layer directly. + * + * Conventions: + * - size <= 0 means "return all rows on a single page". + * - "desc" (case-insensitive) reverses the comparator; anything else is ascending. + * - An unknown sort key falls back to no sort. + */ +public final class PagingUtility { + + private PagingUtility() { + } + + public static PageResponse apply( + List items, + Predicate searchFilter, + Map> comparators, + String sortKey, + String direction, + int page, + int size, + String search) { + + List filtered = (searchFilter == null) + ? new ArrayList<>(items) + : items.stream().filter(searchFilter).collect(Collectors.toList()); + + Comparator comparator = (comparators == null || sortKey == null) + ? null + : comparators.get(sortKey); + if (comparator != null) { + if ("desc".equalsIgnoreCase(direction)) { + comparator = comparator.reversed(); + } + filtered.sort(comparator); + } + + long totalElements = filtered.size(); + List content; + int totalPages; + int pageOut = page; + + if (size <= 0) { + content = filtered; + totalPages = 1; + pageOut = 0; + } else { + totalPages = (int) Math.max(1, (long) Math.ceil((double) totalElements / size)); + if (pageOut < 0) { + pageOut = 0; + } + if (pageOut > totalPages - 1) { + pageOut = totalPages - 1; + } + int from = pageOut * size; + int to = (int) Math.min((long) from + size, totalElements); + content = (from >= filtered.size()) + ? new ArrayList<>() + : new ArrayList<>(filtered.subList(from, to)); + } + + return new PageResponse<>( + content, + totalElements, + totalPages, + pageOut, + size, + sortKey, + direction, + search); + } + + /** + * Null-safe case-insensitive substring match. Returns false when haystack + * is null and true when needle is empty (no-op filter). + */ + public static boolean contains(String haystack, String needle) { + if (needle == null || needle.isEmpty()) { + return true; + } + if (haystack == null) { + return false; + } + return haystack.toLowerCase().contains(needle); + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css index f16cd2a8..e402d4c4 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/style.css @@ -42,7 +42,7 @@ body { /* Table Wrapper */ .table-wrapper { max-height: 400px; - overflow-y: auto; + overflow: auto; margin-top: 1em; } @@ -152,4 +152,74 @@ label small { } .footer .footer-links a:hover { color: #fff; -} \ No newline at end of file +} +/* Admin panel: search + pager + sortable headers */ +.admin-toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 10px; +} +.admin-toolbar .admin-search-input { + max-width: 280px; + display: inline-block; + width: auto; +} +#users-table thead th.sortable { + cursor: pointer; + user-select: none; +} +#users-table thead th .sort-indicator { + font-size: 0.75em; + margin-left: 4px; + color: #777; +} +#users-table thead th.sort-asc .sort-indicator::after { + content: "\25B2"; + color: #222; +} +#users-table thead th.sort-desc .sort-indicator::after { + content: "\25BC"; + color: #222; +} +.admin-pager { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 10px; +} +.admin-pager .page-info { + color: #777; + font-size: 0.9em; +} + +/* Resizable columns */ +.table-resizable { + table-layout: fixed; + width: 100%; +} +.table-resizable th { + position: relative; + white-space: nowrap; +} +.table-resizable td { + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + vertical-align: top; +} +.col-resizer { + position: absolute; + top: 0; + right: 0; + width: 6px; + height: 100%; + cursor: col-resize; + user-select: none; + z-index: 2; +} +.col-resizer:hover { + background: rgba(0, 123, 255, 0.25); +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js index 22d412e8..3dcf74b5 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/main.js @@ -27,6 +27,9 @@ const SELECTORS = { adminSection: "#admin-section", loadUsersBtn: "#load-users-btn", usersTableBody: "#users-tbody", + usersTable: "#users-table", + adminUsersSearch: "#admin-users-search", + adminUsersPager: "#admin-users-pager", openAddUserModalBtn: "#open-add-user-modal-btn", // Modals: Add User @@ -51,6 +54,7 @@ const ENDPOINTS = { logoutAllDevices: "/logout-session", csrfToken: "/csrf-token", adminUsers: "/admin/users", + adminUsersPage: "/admin/users/page", adminRegisterUser: "/admin/register-user", adminDeleteUser: "/admin/delete-user", adminToggleUserStatus: "/admin/toggle-user-status", @@ -902,43 +906,234 @@ function logoutAllDevices(successCallback) { ============ ADMIN LOGIC ========= ----------------------------------------- */ function loadAllUsers() { - $.ajax({ - url: ENDPOINTS.adminUsers, - method: "GET", - success: (data) => { - populateUsersTable(data); - }, - error: (err) => { - alert("Failed to load users"); - console.error(err); - }, + adminUsersTableController.reload(); +} + +/** + * Make table columns drag-resizable. Switches the table to fixed layout, + * locks each at its current width, and adds a right-edge drag handle. + */ +function enableColumnResize(tableEl) { + if (!tableEl || tableEl.dataset.resizableInit === "1") return; + tableEl.dataset.resizableInit = "1"; + tableEl.classList.add("table-resizable"); + + tableEl.querySelectorAll("thead th").forEach((th) => { + if (!th.style.width) th.style.width = th.offsetWidth + "px"; + + const handle = document.createElement("div"); + handle.className = "col-resizer"; + th.appendChild(handle); + + let startX = 0; + let startW = 0; + const onMove = (e) => { + const dx = e.clientX - startX; + th.style.width = Math.max(40, startW + dx) + "px"; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.userSelect = ""; + }; + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + startX = e.clientX; + startW = th.offsetWidth; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + handle.addEventListener("click", (e) => e.stopPropagation()); }); } -function populateUsersTable(users) { - const tbody = $(SELECTORS.usersTableBody); - tbody.empty(); - if (!Array.isArray(users)) return; +/** + * Admin Users table controller — server-driven sort/filter/paginate. + * Built lazily on first reload so it can attach handlers once. State is + * thin (no client-side rows cache) since the backend owns every query. + */ +const adminUsersTableController = (() => { + const state = { + initialized: false, + content: [], + totalElements: 0, + totalPages: 1, + page: 0, // 0-based to match backend + pageSize: 10, // -1 means "All" + sortKey: "username", + sortDir: "asc", + search: "", + searchDebounce: null, + }; - users.forEach((user) => { - const row = $(""); - row.append($("").text(user.username)); - row.append($("").text(user.enabled ? "Enabled" : "Disabled")); + function reload() { + init(); + const params = { + page: state.page, + size: state.pageSize, + sort: state.sortKey, + direction: state.sortDir, + search: state.search, + }; + return $.ajax({ + url: ENDPOINTS.adminUsersPage, + method: "GET", + data: params, + }) + .done((data) => { + state.content = Array.isArray(data.content) ? data.content : []; + state.totalElements = + typeof data.totalElements === "number" + ? data.totalElements + : state.content.length; + state.totalPages = + typeof data.totalPages === "number" && data.totalPages > 0 + ? data.totalPages + : 1; + if (typeof data.page === "number") state.page = data.page; + render(); + }) + .fail((err) => { + alert("Failed to load users"); + console.error(err); + }); + } + + function render() { + const tbody = $(SELECTORS.usersTableBody); + tbody.empty(); - const actionTd = $(""); - const editBtn = $(""); - editBtn.click(() => showEditUserModal(user)); + state.content.forEach((user) => { + const row = $(""); + row.append($("").text(user.username)); + row.append($("").text(user.enabled ? "Enabled" : "Disabled")); - const deleteBtn = $( - "" + const actionTd = $(""); + const editBtn = $(""); + editBtn.click(() => showEditUserModal(user)); + + const deleteBtn = $( + "" + ); + deleteBtn.click(() => deleteUser(user.username)); + + actionTd.append(editBtn).append(deleteBtn); + row.append(actionTd); + tbody.append(row); + }); + + // Header sort indicators + $(SELECTORS.usersTable + " thead th[data-key]").each(function () { + const $th = $(this); + $th.removeClass("sort-asc sort-desc"); + if ($th.data("key") === state.sortKey) { + $th.addClass(state.sortDir === "asc" ? "sort-asc" : "sort-desc"); + } + }); + + // Pager + const $pager = $(SELECTORS.adminUsersPager); + $pager.empty(); + const total = state.totalElements; + const usingAll = state.pageSize <= 0; + const lo = + total === 0 ? 0 : usingAll ? 1 : state.page * state.pageSize + 1; + const hi = usingAll + ? total + : Math.min((state.page + 1) * state.pageSize, total); + + const $prev = $( + "" + ).prop("disabled", usingAll || state.page <= 0); + $prev.click(() => { + state.page = Math.max(0, state.page - 1); + reload(); + }); + + const $pageLabel = $("").text( + `Page ${state.page + 1} / ${state.totalPages}` ); - deleteBtn.click(() => deleteUser(user.username)); - actionTd.append(editBtn).append(deleteBtn); - row.append(actionTd); - tbody.append(row); - }); -} + const $next = $( + "" + ).prop("disabled", usingAll || state.page >= state.totalPages - 1); + $next.click(() => { + state.page = Math.min(state.totalPages - 1, state.page + 1); + reload(); + }); + + const $size = $("").css({ + display: "inline-block", + width: "auto", + }); + const pageSizeOptions = [ + { value: 10, label: "10 / page" }, + { value: 25, label: "25 / page" }, + { value: 50, label: "50 / page" }, + { value: 100, label: "100 / page" }, + { value: -1, label: "All" }, + ]; + pageSizeOptions.forEach(({ value, label }) => { + $size.append( + $("") + .val(value) + .text(label) + .prop("selected", value === state.pageSize) + ); + }); + $size.change(function () { + state.pageSize = Number($(this).val()); + state.page = 0; + reload(); + }); + + const $info = $("").text( + `${lo}-${hi} of ${total}` + ); + + $pager + .append($prev) + .append($pageLabel) + .append($next) + .append($size) + .append($info); + } + + function init() { + if (state.initialized) return; + state.initialized = true; + + enableColumnResize(document.querySelector(SELECTORS.usersTable)); + + // Header click → toggle sort + reload from backend + $(SELECTORS.usersTable + " thead th.sortable").on("click", function () { + const key = $(this).data("key"); + if (!key) return; + if (state.sortKey === key) { + state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; + } else { + state.sortKey = key; + state.sortDir = "asc"; + } + state.page = 0; + reload(); + }); + + // Search input (debounced) → reload from backend + $(SELECTORS.adminUsersSearch).on("input", function () { + state.search = $(this).val(); + state.page = 0; + clearTimeout(state.searchDebounce); + state.searchDebounce = setTimeout(reload, 250); + }); + } + + return { + reload, + }; +})(); function deleteUser(username) { if (!confirm(`Are you sure you want to delete user '${username}'?`)) { diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html index 34a43b86..383c8878 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html @@ -186,7 +186,7 @@

Admin Panel

for reuse after the server is restarted.

-
+
@@ -197,6 +197,13 @@

Admin Panel

> Add New User +
@@ -207,9 +214,9 @@

Admin Panel

> - Username - Enabled - Actions + Username + Enabled + Actions @@ -217,6 +224,7 @@

Admin Panel

+
diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html index 9d3997d5..2d96c631 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/advance.html @@ -27,11 +27,33 @@ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); } .table-responsive { - max-height: 300px; + max-height: 420px; overflow: auto; } .table thead th { white-space: nowrap; + position: sticky; + top: 0; + background: #f1f3f5; + z-index: 1; + cursor: pointer; + user-select: none; + } + .table thead th[data-sortable="false"] { + cursor: default; + } + .table thead th .sort-indicator { + font-size: 0.75rem; + margin-left: 4px; + color: #6c757d; + } + .table thead th.sort-asc .sort-indicator::after { + content: "▲"; + color: #212529; + } + .table thead th.sort-desc .sort-indicator::after { + content: "â–ŧ"; + color: #212529; } .unlock-user-btn { margin-top: 32px; @@ -45,6 +67,54 @@ font-size: 0.95rem; color: #212529; } + .table-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; + } + .table-toolbar .search-input { + max-width: 280px; + } + .table-pager { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + margin-top: 8px; + } + .table-pager .page-info { + color: #6c757d; + font-size: 0.9rem; + } + /* Resizable columns */ + .table-resizable { + table-layout: fixed; + width: 100%; + } + .table-resizable th { + white-space: nowrap; + } + .table-resizable td { + white-space: normal; + word-break: break-word; + overflow-wrap: break-word; + vertical-align: top; + } + .col-resizer { + position: absolute; + top: 0; + right: 0; + width: 6px; + height: 100%; + cursor: col-resize; + user-select: none; + z-index: 2; + } + .col-resizer:hover { + background: rgba(0, 123, 255, 0.25); + } @@ -57,35 +127,44 @@

ClipCascade - Advance

User Details

- +
+ + +
- +
- - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - +
UsernameMarked for DeletionFirst SignupLast Login ↓Signup IPLast Login IPFailed Login AttemptsLockout TimePassword Changed AtEmailOTPPhoneFirst NameLast NameNotesUsernameEnabledMarked for DeletionFirst SignupLast LoginSignup IPLast Login IPFailed Login AttemptsLockout TimePassword Changed AtEmailOTPPhoneFirst NameLast NameNotes
+
@@ -94,41 +173,78 @@

WebSocket Stats

-
- +
+
+ + +
+

Online Users

+
+ + + +
+
+ + + + + + + + +
UsernameOpen Connections
+
- +

Brute Force Protection Tracker Snapshot

- - +
+ + + +
-
- +
- - - - - - - + + + + + + + - - - +
UsernameSource IPAttempts from IPIP Lock CountIP Lock ExpiresUnique IP AddressesUser Lock ExpiresUsernameSource IPAttempts from IPIP Lock CountIP Lock ExpiresUnique IP AddressesUser Lock Expires
+
- +

Unlock User

@@ -153,18 +269,16 @@

Unlock User

- +

Server Time

-
- -
+
- +

Environment Variables

@@ -182,20 +296,19 @@

Environment Variables

*/ const ENDPOINTS = { USER_DETAILS: "/admin/user-details", + USER_DETAILS_PAGE: "/admin/user-details/page", WEBSOCKET_STATS: "/admin/websocket-stats", + ONLINE_USERS: "/admin/online-users", + ONLINE_USERS_PAGE: "/admin/online-users/page", BFA_SNAPSHOT: "/admin/bfa-snapshot", + BFA_SNAPSHOT_PAGE: "/admin/bfa-snapshot/page", BFA_SNAPSHOT_FILE: "/admin/bfa-snapshot-file", UNLOCK_USER: "/admin/unlock-user", SERVER_TIME: "/admin/server-time", }; /** - * A reusable fetch function that handles basic error checking. - * - * @param {string} url - The URL endpoint to call. - * @param {string} [method='GET'] - The HTTP method, default is GET. - * @param {Object|null} [body=null] - The request body, if any. - * @returns {Promise} - The fetch response object. + * Generic fetch helper with basic error checking. */ async function fetchData(url, method = "GET", body = null, headers = {}) { try { @@ -216,11 +329,7 @@

Environment Variables

} /** - * Format an epoch time in seconds to a human-readable, locale-specific - * string in the format "YYYY MMM DD HH:MM:SS AM/PM TZ". - * - * @param {number} epoch - The epoch time in seconds. - * @returns {string} - The formatted string, empty if the input is null or 0. + * Format epoch (seconds) to a human-readable, locale-specific string. */ function formatEpochToLocal(epoch) { if (epoch == null || epoch === 0) return ""; @@ -245,37 +354,320 @@

Environment Variables

} /** - * 1) Fetch and display user details. + * Make table columns drag-resizable. Switches the table to fixed layout, + * locks each at its current width, and adds a right-edge drag handle. */ - async function getUserDetails() { - try { - const response = await fetchData(ENDPOINTS.USER_DETAILS); - const data = await response.json(); - const tbody = document.getElementById("user-details-tbody"); - tbody.innerHTML = ""; + function enableColumnResize(tableEl) { + if (!tableEl || tableEl.dataset.resizableInit === "1") return; + tableEl.dataset.resizableInit = "1"; + tableEl.classList.add("table-resizable"); + + tableEl.querySelectorAll("thead th").forEach((th) => { + if (!th.style.width) th.style.width = th.offsetWidth + "px"; + + const handle = document.createElement("div"); + handle.className = "col-resizer"; + th.appendChild(handle); + + let startX = 0; + let startW = 0; + const onMove = (e) => { + const dx = e.clientX - startX; + th.style.width = Math.max(40, startW + dx) + "px"; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.userSelect = ""; + }; + handle.addEventListener("mousedown", (e) => { + e.preventDefault(); + e.stopPropagation(); + startX = e.clientX; + startW = th.offsetWidth; + document.body.style.userSelect = "none"; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }); + handle.addEventListener("click", (e) => e.stopPropagation()); + }); + } - const dateKeys = ["firstSignup", "lastLogin", "passwordChangedAt"]; - data.forEach((user) => { - const row = document.createElement("tr"); - Object.keys(user).forEach((key) => { - const cell = document.createElement("td"); - let text = user[key]; - if (dateKeys.includes(key)) { - text = formatEpochToLocal(user[key]); + /** + * Calls a paged admin endpoint and returns the PageResponse JSON. + * Backend treats size <= 0 as "all rows" and accepts 0-based page index. + */ + async function fetchPage(url, params) { + const qs = new URLSearchParams(); + if (params.page != null) qs.set("page", String(params.page)); + if (params.size != null) qs.set("size", String(params.size)); + if (params.sort) qs.set("sort", params.sort); + if (params.direction) qs.set("direction", params.direction); + if (params.search) qs.set("search", params.search); + const res = await fetchData(`${url}?${qs.toString()}`); + return await res.json(); + } + + /** + * Generic data-table controller backed by a server endpoint. + * The endpoint owns sort/filter/paginate; this just renders the page + * it returns and forwards user actions (sort click, search, page nav, + * page-size change) back as fresh requests. + */ + function createDataTable(opts) { + const tableEl = document.querySelector( + `table[data-table-id="${opts.tableId}"]` + ); + const tbody = document.getElementById(opts.tbodyId); + const searchEl = document.getElementById(opts.searchId); + const pagerEl = document.getElementById(opts.pagerId); + + const state = { + content: [], + totalElements: 0, + totalPages: 1, + page: 0, // 0-based to match backend + pageSize: 10, // -1 means "All" + sortKey: opts.defaultSortKey || null, + sortDir: opts.defaultSortDir || "asc", + search: "", + }; + + async function reload() { + if (!opts.fetcher) return; + try { + const data = await opts.fetcher({ + page: state.page, + size: state.pageSize, + sort: state.sortKey, + direction: state.sortDir, + search: state.search, + }); + state.content = Array.isArray(data.content) ? data.content : []; + state.totalElements = + typeof data.totalElements === "number" + ? data.totalElements + : state.content.length; + state.totalPages = + typeof data.totalPages === "number" && data.totalPages > 0 + ? data.totalPages + : 1; + if (typeof data.page === "number") state.page = data.page; + render(); + } catch (err) { + console.error(err); + if (opts.onError) opts.onError(err); + } + } + + function render() { + tbody.innerHTML = ""; + state.content.forEach((row) => { + const tr = document.createElement("tr"); + opts.columns.forEach((col) => { + const td = document.createElement("td"); + let v = row[col.key]; + if (col.type === "date") { + v = v ? formatEpochToLocal(v) : ""; + } else if (col.type === "bool") { + v = v === true ? "Enabled" : v === false ? "Disabled" : ""; + } else if (v == null) { + v = ""; } - cell.textContent = text ?? ""; - row.appendChild(cell); + td.textContent = v; + tr.appendChild(td); }); - tbody.appendChild(row); + tbody.appendChild(tr); }); - } catch (err) { - console.error(err); - alert("Error fetching user details."); + + // Header sort indicators + tableEl.querySelectorAll("thead th[data-key]").forEach((h) => { + h.classList.remove("sort-asc", "sort-desc"); + if (h.dataset.key === state.sortKey) { + h.classList.add( + state.sortDir === "asc" ? "sort-asc" : "sort-desc" + ); + } + }); + + // Pager + pagerEl.innerHTML = ""; + const total = state.totalElements; + const usingAll = state.pageSize <= 0; + const lo = + total === 0 + ? 0 + : usingAll + ? 1 + : state.page * state.pageSize + 1; + const hi = usingAll + ? total + : Math.min((state.page + 1) * state.pageSize, total); + + const prev = document.createElement("button"); + prev.className = "btn btn-outline-secondary btn-sm"; + prev.textContent = "Prev"; + prev.disabled = usingAll || state.page <= 0; + prev.onclick = () => { + state.page = Math.max(0, state.page - 1); + reload(); + }; + + const next = document.createElement("button"); + next.className = "btn btn-outline-secondary btn-sm"; + next.textContent = "Next"; + next.disabled = usingAll || state.page >= state.totalPages - 1; + next.onclick = () => { + state.page = Math.min(state.totalPages - 1, state.page + 1); + reload(); + }; + + const pageLabel = document.createElement("span"); + pageLabel.className = "page-info"; + pageLabel.textContent = `Page ${state.page + 1} / ${state.totalPages}`; + + const sizeSel = document.createElement("select"); + sizeSel.className = "form-select form-select-sm"; + sizeSel.style.width = "auto"; + const sizeOptions = [ + { value: 10, label: "10 / page" }, + { value: 25, label: "25 / page" }, + { value: 50, label: "50 / page" }, + { value: 100, label: "100 / page" }, + { value: -1, label: "All" }, + ]; + sizeOptions.forEach(({ value, label }) => { + const o = document.createElement("option"); + o.value = String(value); + o.textContent = label; + if (value === state.pageSize) o.selected = true; + sizeSel.appendChild(o); + }); + sizeSel.onchange = (e) => { + state.pageSize = Number(e.target.value); + state.page = 0; + reload(); + }; + + const info = document.createElement("span"); + info.className = "page-info"; + info.textContent = `${lo}-${hi} of ${total}`; + + pagerEl.appendChild(prev); + pagerEl.appendChild(pageLabel); + pagerEl.appendChild(next); + pagerEl.appendChild(sizeSel); + pagerEl.appendChild(info); } + + enableColumnResize(tableEl); + + // Header click → toggle sort + reload from backend + tableEl.querySelectorAll("thead th[data-key]").forEach((h) => { + if (h.dataset.sortable === "false") return; + h.addEventListener("click", () => { + const key = h.dataset.key; + if (state.sortKey === key) { + state.sortDir = state.sortDir === "asc" ? "desc" : "asc"; + } else { + state.sortKey = key; + state.sortDir = "asc"; + } + state.page = 0; + reload(); + }); + }); + + // Search input (debounced) → reload from backend + if (searchEl) { + let timer = null; + searchEl.addEventListener("input", (e) => { + state.search = e.target.value; + state.page = 0; + clearTimeout(timer); + timer = setTimeout(reload, 250); + }); + } + + // Render empty pager so the UI shows the controls before the first fetch + render(); + + return { + reload, + }; } + // --- Table instances (server-driven sort/filter/paginate) --- + const userDetailsTable = createDataTable({ + tableId: "userDetails", + tbodyId: "user-details-tbody", + searchId: "user-details-search", + pagerId: "user-details-pager", + columns: [ + { key: "username" }, + { key: "enabled", type: "bool" }, + { key: "markedForDeletion" }, + { key: "firstSignup", type: "date" }, + { key: "lastLogin", type: "date" }, + { key: "firstSignupIp" }, + { key: "lastLoginIp" }, + { key: "failedLoginAttempts", type: "number" }, + { key: "lockoutTime" }, + { key: "passwordChangedAt", type: "date" }, + { key: "email" }, + { key: "otp" }, + { key: "phone" }, + { key: "firstName" }, + { key: "lastName" }, + { key: "notes" }, + ], + defaultSortKey: "lastLogin", + defaultSortDir: "desc", + fetcher: (params) => fetchPage(ENDPOINTS.USER_DETAILS_PAGE, params), + onError: () => alert("Error fetching user details."), + }); + + const onlineUsersTable = createDataTable({ + tableId: "onlineUsers", + tbodyId: "online-users-tbody", + searchId: "online-users-search", + pagerId: "online-users-pager", + columns: [{ key: "username" }, { key: "connections", type: "number" }], + defaultSortKey: "connections", + defaultSortDir: "desc", + fetcher: (params) => fetchPage(ENDPOINTS.ONLINE_USERS_PAGE, params), + onError: () => alert("Error fetching online users."), + }); + + const bfaSnapshotTable = createDataTable({ + tableId: "bfaSnapshot", + tbodyId: "bfa-snapshot-tbody", + searchId: "bfa-snapshot-search", + pagerId: "bfa-snapshot-pager", + columns: [ + { key: "username" }, + { key: "sourceIp" }, + { key: "attempts", type: "number" }, + { key: "lockCount", type: "number" }, + { key: "lockTimeout", type: "date" }, + { key: "uniqueIpCount", type: "number" }, + { key: "userLockTimeout", type: "date" }, + ], + defaultSortKey: "username", + defaultSortDir: "asc", + fetcher: (params) => fetchPage(ENDPOINTS.BFA_SNAPSHOT_PAGE, params), + onError: () => alert("Error fetching BFA tracker data."), + }); + /** - * 2) Fetch and display WebSocket stats. + * 1) User details — reload the current backend page + */ + function getUserDetails() { + return userDetailsTable.reload(); + } + + /** + * 2) WebSocket stats */ async function getWebsocketStats() { try { @@ -290,87 +682,51 @@

Environment Variables

} /** - * 3a) Fetch Brute Force Protection Tracker data (JSON) and populate table. + * 3) Online users — refresh both the table (paged) and the + * "N user(s), M connection(s)" summary (computed over the full + * map via the legacy unpaged endpoint, since the summary needs + * the total connection count, not just the current page). */ - async function getBfaTracker() { + async function getOnlineUsers() { try { - const response = await fetchData(ENDPOINTS.BFA_SNAPSHOT); - const trackerData = await response.json(); - const tbody = document.getElementById("bfa-snapshot-tbody"); - tbody.innerHTML = ""; - - for (const [username, tracker] of Object.entries(trackerData)) { - for (const [ip, details] of Object.entries( - tracker.ipAccessDetails || {} - )) { - const row = document.createElement("tr"); - - // Username - row.appendChild( - Object.assign(document.createElement("td"), { - textContent: username, - }) - ); - // IP - row.appendChild( - Object.assign(document.createElement("td"), { textContent: ip }) - ); - // Attempts & lock count - row.appendChild( - Object.assign(document.createElement("td"), { - textContent: details.attempts, - }) - ); - row.appendChild( - Object.assign(document.createElement("td"), { - textContent: details.lockCount, - }) - ); - - // Lock timeout (epoch → formatted) - const lockTd = document.createElement("td"); - lockTd.textContent = formatEpochToLocal(details.lockTimeout); - row.appendChild(lockTd); - - // Unique IPs & user lock timeout - row.appendChild( - Object.assign(document.createElement("td"), { - textContent: (tracker.uniqueIpSet || []).length, - }) - ); - const userLockTd = document.createElement("td"); - userLockTd.textContent = formatEpochToLocal(tracker.lockTimeout); - row.appendChild(userLockTd); - - tbody.appendChild(row); - } - } + const response = await fetchData(ENDPOINTS.ONLINE_USERS); + const data = await response.json(); + const totalUsers = Object.keys(data || {}).length; + const totalConnections = Object.values(data || {}).reduce( + (sum, n) => sum + (Number(n) || 0), + 0 + ); + document.getElementById( + "online-users-summary" + ).textContent = `${totalUsers} user(s), ${totalConnections} connection(s)`; } catch (err) { console.error(err); - alert("Error fetching BFA tracker data."); } + return onlineUsersTable.reload(); } /** - * 3b) Download Brute Force Protection Tracker Snapshot (as file). + * 4) BFA tracker — reload the current backend page (flattening is + * now done server-side). + */ + function getBfaTracker() { + return bfaSnapshotTable.reload(); + } + + /** + * Download BFA snapshot */ async function downloadBfaSnapshot() { try { const response = await fetchData(ENDPOINTS.BFA_SNAPSHOT_FILE); const blob = await response.blob(); - - // Generate a timestamp-based filename const timestamp = new Date().toISOString().replace(/[:.]/g, "-"); const fileName = `brute_force_tracker_snapshot_${timestamp}.json`; - - // Create a link to download the file const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = fileName; link.click(); - - // Cleanup URL.revokeObjectURL(url); alert("Snapshot downloaded successfully!"); } catch (error) { @@ -380,31 +736,24 @@

Environment Variables

} /** - * 4) Unlock user based on form submission. + * Unlock user */ async function unlockUser(event) { - event.preventDefault(); // Prevent default form submission - + event.preventDefault(); const form = document.getElementById("unlock-user-form"); - const formData = new FormData(form); // Get form data - - const username = formData.get("username").trim(); // Retrieve the username - const csrfToken = formData.get("_csrf"); // Retrieve the CSRF token - + const formData = new FormData(form); + const username = formData.get("username").trim(); + const csrfToken = formData.get("_csrf"); if (!username) { alert("Please enter a username to unlock."); return; } - try { - // PUT request to unlock the user await fetchData( ENDPOINTS.UNLOCK_USER, "PUT", { username }, - { - "X-CSRF-TOKEN": csrfToken, - } + { "X-CSRF-TOKEN": csrfToken } ); alert("User unlocked successfully!"); } catch (error) { @@ -414,7 +763,7 @@

Environment Variables

} /** - * 5) Fetch and show server time. + * Server time */ async function showServerTime() { try { @@ -429,10 +778,10 @@

Environment Variables

} ClipCascade @@ -13,8 +15,9 @@ + - + @@ -34,6 +37,16 @@