diff --git a/.github/scripts/apply_app_version.py b/.github/scripts/apply_app_version.py index 0980ff6c..fb619f19 100644 --- a/.github/scripts/apply_app_version.py +++ b/.github/scripts/apply_app_version.py @@ -6,7 +6,7 @@ Targets: - ClipCascade_Desktop/src/core/constants.py (windows, macos, linux_gui, linux_non_gui) - - ClipCascade_Mobile/src/App.js (android) + - ClipCascade_Mobile/src/AppVersion.js (android) - ClipCascade_Mobile/src/android/app/build.gradle (android versionName + versionCode) - ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java (server) @@ -76,7 +76,7 @@ def patch_constants_py() -> None: def patch_app_js() -> None: - path = REPO_ROOT / "ClipCascade_Mobile" / "src" / "App.js" + path = REPO_ROOT / "ClipCascade_Mobile" / "src" / "AppVersion.js" replace_in_file( path, r"const APP_VERSION = '[^']+';", diff --git a/.github/workflows/README.md b/.github/workflows/README.md index 390ca0c1..f3924468 100644 --- a/.github/workflows/README.md +++ b/.github/workflows/README.md @@ -131,7 +131,7 @@ the same source constants on `main`: | version.json key | Synced into | |----------------------------------|------------------------------------------------------------------------------| | `windows` / `macos` / `linux_*` | `ClipCascade_Desktop/src/core/constants.py` (`APP_VERSION` per platform) | -| `android` | `ClipCascade_Mobile/src/App.js` (`APP_VERSION`) | +| `android` | `ClipCascade_Mobile/src/AppVersion.js` (`APP_VERSION`) | | `android` | `…/android/app/build.gradle` (`versionName` + computed `versionCode`) | | `server` | `…/constants/ServerConstants.java` (`APP_VERSION`) | | `server` | `ClipCascade_Server/docker-compose/docker-compose.yml` (image tag) | @@ -190,7 +190,7 @@ The `macos` scope builds two native packages with the same macOS version: | Processor | GitHub runner | Archive | |---|---|---| -| Apple Silicon | `macos-14` (`arm64`) | `ClipCascade-Apple_macOS(ARM_M-Series).zip` | +| Apple Silicon | `macos-15` (`arm64`) | `ClipCascade-Apple_macOS(ARM_M-Series).zip` | | Intel | `macos-15-intel` (`x86_64`) | `ClipCascade-Apple_macOS(Intel-Series).zip` | Both architectures use exactly **Python 3.12.10** from the signed python.org @@ -200,9 +200,15 @@ dependency installation if the Python version or running architecture differs from the required values. Tcl/Tk versions are not separately checked. This replaces the previous Intel Python 3.11.2 pin. -Each job also checks aiortc/PyAV imports, uses the existing PyInstaller -specification, and verifies the packaged executable's architecture before uploading -its ZIP. These checks do not replace testing P2P in the packaged app. +Each job also checks aiortc/PyAV imports, builds with PyInstaller's `--clean` +option, and verifies the packaged executable's architecture. Before uploading, +it runs the actual bundled executable with `--self-test`, allowing up to 5 +minutes for launch and execution to accommodate macOS first-launch security +checks. The CI step has a separate 6-minute limit. These limits do not bypass +security checks or approve interactive prompts; a launch requiring manual +approval still needs attention. This headless check imports the native TLS/media dependencies, +generates a certificate, and creates a P2P data-channel offer without starting +the GUI. These checks do not replace testing P2P between devices. Both builds must succeed for the release to publish. When macOS is unchanged, both ZIPs are carried over from the previous release. @@ -212,6 +218,34 @@ prebuilt PyAV wheels. If PyAV falls back to source, its supported FFmpeg version must match the installed libraries; installing the tools alone does not guarantee source-build compatibility. +`requirements_mac.txt` requires a binary wheel for `cryptography`; CI disables +pip's cache for this installation. The official macOS wheels statically link +OpenSSL. A dynamically linked source build can load a different `libssl.3.dylib` +after PyInstaller rewrites library paths, causing errors such as +`Symbol not found: _SSL_get0_group_name` at startup. The macOS spec checks the +Rust binding with `otool -L` and rejects shared OpenSSL dependencies before +packaging. Installing another OpenSSL on the user's Mac does not repair the +library already embedded in the executable. + +For a local rebuild, start with a **new** virtual environment using the same +python.org Python 3.12.10 interpreter as CI. From `ClipCascade_Desktop/src`: + +```sh +/Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 -m venv .venv-macos +source .venv-macos/bin/activate +python3 -m pip install --upgrade pip +python3 -m pip install --upgrade pyinstaller pyinstaller-hooks-contrib +python3 -m pip install --no-cache-dir -r requirements_mac.txt +python3 -m PyInstaller --clean --noconfirm ClipCascade_macos.spec +dist/ClipCascade.app/Contents/MacOS/ClipCascade --self-test +``` + +Use a new venv name if `.venv-macos` already exists. Reusing an environment with +an installed source build can leave that build in place even with +`--only-binary`. Run the self-test on each target architecture and test the GUI +on the oldest macOS release you intend to support; a successful build on a newer +GitHub runner does not establish compatibility with macOS 12. + Include `macos` (or `desktop` / `all`) in the first releasing PR after adding Intel support so the new Intel ZIP is generated; older releases may not contain it. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5d9e51ae..d15cebb2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -223,7 +223,7 @@ jobs: fail-fast: false matrix: include: - - runner: macos-14 + - runner: macos-15 arch: arm64 archive: ClipCascade-Apple_macOS(ARM_M-Series).zip - runner: macos-15-intel @@ -246,6 +246,7 @@ jobs: --output "$PKG" pkgutil --check-signature "$PKG" sudo installer -pkg "$PKG" -target / + sudo "/Applications/Python 3.12/Install Certificates.command" /Library/Frameworks/Python.framework/Versions/3.12/bin/python3.12 \ -m venv "$RUNNER_TEMP/clipcascade-python" echo "$RUNNER_TEMP/clipcascade-python/bin" >> "$GITHUB_PATH" @@ -286,7 +287,7 @@ jobs: run: | python3 -m pip install --upgrade pip certifi python3 -m pip install pyinstaller - python3 -m pip install -r requirements_mac.txt + python3 -m pip install --no-cache-dir -r requirements_mac.txt - name: Check aiortc before packaging run: | @@ -303,7 +304,7 @@ jobs: - name: Build ClipCascade.app with PyInstaller working-directory: ClipCascade_Desktop/src - run: python3 -m PyInstaller ClipCascade_macos.spec --noconfirm + run: python3 -m PyInstaller ClipCascade_macos.spec --clean --noconfirm - name: Verify packaged executable architecture env: @@ -311,6 +312,21 @@ jobs: run: | lipo -verify_arch "$EXPECTED_ARCH" ClipCascade_Desktop/src/dist/ClipCascade.app/Contents/MacOS/ClipCascade + - name: Test packaged native dependencies + timeout-minutes: 6 + run: | + python3 - <<'PY' + import subprocess + + # Allow extra time for macOS security checks on the first launch. + print("Starting packaged self-test; allowing up to 5 minutes for launch and checks.", flush=True) + subprocess.run( + ["ClipCascade_Desktop/src/dist/ClipCascade.app/Contents/MacOS/ClipCascade", "--self-test"], + check=True, + timeout=300, + ) + PY + - name: Stage and zip (on macOS to preserve attributes) env: ARCHIVE_NAME: ${{ matrix.archive }} @@ -633,7 +649,7 @@ jobs: git config user.email "41898282+github-actions[bot]@users.noreply.github.com" git add version.json \ ClipCascade_Desktop/src/core/constants.py \ - ClipCascade_Mobile/src/App.js \ + ClipCascade_Mobile/src/AppVersion.js \ ClipCascade_Mobile/src/android/app/build.gradle \ ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/constants/ServerConstants.java \ ClipCascade_Server/docker-compose/docker-compose.yml diff --git a/.gitignore b/.gitignore index 2c00c6bd..b3eb1033 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ private/ screenshots/ +/database/ +/logs/ .DS_Store .vscode/ diff --git a/ClipCascade_Desktop/src/ClipCascade_macos.spec b/ClipCascade_Desktop/src/ClipCascade_macos.spec index 093361e4..ce5d72c6 100644 --- a/ClipCascade_Desktop/src/ClipCascade_macos.spec +++ b/ClipCascade_Desktop/src/ClipCascade_macos.spec @@ -1,6 +1,26 @@ # -*- mode: python ; coding: utf-8 -*- import certifi +import importlib.util +import re +import subprocess + +# PyInstaller rewrites dylib paths inside the bundle. A dynamically linked +# cryptography can then resolve to Python's different OpenSSL with the same +# filename, even when imports in the unfrozen environment succeed. +rust_binding = importlib.util.find_spec('cryptography.hazmat.bindings._rust') +if rust_binding is None or not rust_binding.origin: + raise SystemExit('cryptography Rust binding is missing; install requirements_mac.txt') +linked_libraries = subprocess.check_output( + ['otool', '-L', rust_binding.origin], text=True +) +if re.search(r'/lib(?:ssl|crypto)[^/\s]*\.dylib', linked_libraries): + raise SystemExit( + 'cryptography links to a shared OpenSSL library, which can collide with ' + 'the bundled Python OpenSSL. Reinstall official wheels in a fresh venv: ' + 'python3 -m pip install --no-cache-dir -r requirements_mac.txt\n' + + linked_libraries + ) a = Analysis( ['main.py'], @@ -10,7 +30,7 @@ a = Analysis( hiddenimports=['tkinter'], hookspath=[], hooksconfig={}, - runtime_hooks=[], + runtime_hooks=['packaging/macos_self_test.py'], excludes=[], noarchive=False, optimize=0, diff --git a/ClipCascade_Desktop/src/cli/login.py b/ClipCascade_Desktop/src/cli/login.py index d11845bd..14d04417 100644 --- a/ClipCascade_Desktop/src/cli/login.py +++ b/ClipCascade_Desktop/src/cli/login.py @@ -63,28 +63,36 @@ def mainloop(self): self.config.data["cipher_enabled"] = LoginForm.str_to_bool( input( - f"enabled encryption(recommended) [{LoginForm.bool_to_str(self.config.data['cipher_enabled'])}]: " + f"Enable encryption (recommended) [{LoginForm.bool_to_str(self.config.data['cipher_enabled'])}]: " ) or LoginForm.bool_to_str(self.config.data["cipher_enabled"]) ) self.config.data["notification"] = LoginForm.str_to_bool( input( - f"enabled notification [{LoginForm.bool_to_str(self.config.data['notification'])}]: " + f"Show connection status notifications [{LoginForm.bool_to_str(self.config.data['notification'])}]: " ) or LoginForm.bool_to_str(self.config.data["notification"]) ) + self._save_preference("notification") # extra fields - show_extra_fields = input(f"show extra fields [n]: ") or "n" + show_extra_fields = input(f"Show advanced settings [n]: ") or "n" if not LoginForm.str_to_bool(show_extra_fields): CustomDialog("Logging in...").mainloop() return + self.config.data["update_notifications"] = LoginForm.str_to_bool( + input( + f"Notify when an update is available [{LoginForm.bool_to_str(self.config.data['update_notifications'])}]: " + ) or LoginForm.bool_to_str(self.config.data["update_notifications"]) + ) + self._save_preference("update_notifications") + # Validate hash_rounds while True: hash_rounds = input( - f"hash rounds [{self.config.data['hash_rounds']}]: " + f"Key derivation iterations [{self.config.data['hash_rounds']}]: " ) or str( "" if self.config.data["hash_rounds"] is None @@ -94,7 +102,7 @@ def mainloop(self): if not LoginForm.is_positive_integer(hash_rounds): CustomDialog( - "Invalid Input\nHash Rounds must be a positive integer.", + "Invalid Input\nKey derivation iterations must be a positive integer.", msg_type="error", ) continue @@ -103,7 +111,7 @@ def mainloop(self): break self.config.data["salt"] = ( - input(f"salt [{self.config.data['salt']}]: ") or self.config.data["salt"] + input(f"Encryption salt [{self.config.data['salt']}]: ") or self.config.data["salt"] ) self.config.data["save_password"] = LoginForm.str_to_bool( @@ -112,6 +120,7 @@ def mainloop(self): ) or LoginForm.bool_to_str(self.config.data["save_password"]) ) + self._save_preference("save_password") # Validate max_clipboard_size_local_limit_bytes while True: @@ -123,7 +132,7 @@ def mainloop(self): max_clipboard_size_local_limit_bytes = ( input( - f"maximum clipboard size local limit (in bytes) [{saved_mcsll}]: " + f"Local clipboard size limit (bytes) [{saved_mcsll}]: " ) or saved_mcsll ) @@ -132,11 +141,12 @@ def mainloop(self): ) if max_clipboard_size_local_limit_bytes == "": self.config.data["max_clipboard_size_local_limit_bytes"] = None + self._save_preference("max_clipboard_size_local_limit_bytes") break if not LoginForm.is_positive_integer(max_clipboard_size_local_limit_bytes): CustomDialog( - "Invalid Input\nMax Clipboard Size Local Limit must be a positive integer.", + "Invalid Input\nLocal clipboard size limit must be a positive integer.", msg_type="error", ) continue @@ -144,6 +154,7 @@ def mainloop(self): self.config.data["max_clipboard_size_local_limit_bytes"] = int( max_clipboard_size_local_limit_bytes ) + self._save_preference("max_clipboard_size_local_limit_bytes") break self.config.data["enable_image_sharing"] = LoginForm.str_to_bool( @@ -152,6 +163,7 @@ def mainloop(self): ) or LoginForm.bool_to_str(self.config.data["enable_image_sharing"]) ) + self._save_preference("enable_image_sharing") self.config.data["enable_file_sharing"] = LoginForm.str_to_bool( input( @@ -159,12 +171,13 @@ def mainloop(self): ) or LoginForm.bool_to_str(self.config.data["enable_file_sharing"]) ) + self._save_preference("enable_file_sharing") # Validate default file download location while True: default_file_download_location = ( input( - f"default file download location [{self.config.data['default_file_download_location']}]: " + f"Default download folder [{self.config.data['default_file_download_location']}]: " ) or self.config.data["default_file_download_location"] ) @@ -172,6 +185,7 @@ def mainloop(self): default_file_download_location = default_file_download_location.strip() if default_file_download_location == "": self.config.data["default_file_download_location"] = "" + self._save_preference("default_file_download_location") break if not os.path.isdir(default_file_download_location): @@ -184,12 +198,13 @@ def mainloop(self): self.config.data["default_file_download_location"] = ( default_file_download_location ) + self._save_preference("default_file_download_location") break while True: ssl_ca_bundle = ( input( - f"ssl ca bundle path (optional) [{self.config.data.get('ssl_ca_bundle') or ''}]: " + f"Custom CA certificate bundle path [{self.config.data.get('ssl_ca_bundle') or ''}]: " ) or (self.config.data.get("ssl_ca_bundle") or "") ).strip() @@ -198,7 +213,7 @@ def mainloop(self): break if not os.path.isfile(ssl_ca_bundle): CustomDialog( - "Invalid Input\nSSL CA bundle path is not a file or does not exist.", + "Invalid Input\nCustom CA certificate bundle path is not a file or does not exist.", msg_type="error", ).mainloop() continue @@ -207,6 +222,10 @@ def mainloop(self): CustomDialog("Logging in...").mainloop() + def _save_preference(self, field): + if not self.config.save_preferences({field: self.config.data[field]}): + CustomDialog("Unable to save device preferences.", msg_type="error").mainloop() + def on_quit(self): pass diff --git a/ClipCascade_Desktop/src/cli/tray.py b/ClipCascade_Desktop/src/cli/tray.py index a282cfbe..05a46443 100644 --- a/ClipCascade_Desktop/src/cli/tray.py +++ b/ClipCascade_Desktop/src/cli/tray.py @@ -102,7 +102,9 @@ def display_menu(self): for item in self.menu_items: if isinstance(item, tuple): width = max_width - if "disconnect" in item[0].lower() or "logs" in item[0].lower(): + # The version icon uses the same variation selector as Open Logs, + # so both rows need the extra padding to align the right border. + if any(label in item[0].lower() for label in ("disconnect", "logs", "version")): if "disconnecting" in item[0].lower(): width -= 1 else: @@ -118,6 +120,12 @@ def display_menu(self): def create_menu(self, item_: tuple = None): # Menu items + version_suffix = ( + " (latest)" + if self.new_version_available is not None + and self.new_version_available[1] == APP_VERSION + else "" + ) menu_items = [ "", # Menu Separator ("🗒️ Open Logs", self._open_logs), @@ -128,6 +136,8 @@ def create_menu(self, item_: tuple = None): ("💟 Donate", self._open_donate), ("🌐 GitHub", self._open_github), "", # Menu Separator + (f"ℹ️ Version v{APP_VERSION}{version_suffix}", None), # Read-only version display + "", # Menu Separator ("🔒 Logoff and Quit", self._on_logoff), ("❌ Quit", self._on_quit), ] @@ -187,7 +197,18 @@ def update_stats(self): threading.Thread(target=self._update_stats_thread, daemon=True).start() def _update_stats_thread(self): - while True: + while not self.loop_terminate: + checker = getattr(self, "update_checker", None) + if checker: + if checker.status is not None and checker.status != self.new_version_available: + self.new_version_available = checker.status + self.update_menu(no_dialog=True) + status = checker.claim_alert(self.config.data.get("update_notifications", True)) + if status: + CustomDialog( + f"ClipCascade {status[1]} is available. Press Enter to refresh the update menu.", + msg_type="info", + ).mainloop() current_stats = self.ws_interface.get_stats() if current_stats is not None and self.previous_stats != current_stats: self.previous_stats = current_stats diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py index 6a83f64b..351a98f5 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_manager.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_manager.py @@ -57,7 +57,7 @@ def reset_files_download(self): @staticmethod def hash_clipboard(clipboard: str) -> int: - return xxhash.xxh64(clipboard).intdigest() + return xxhash.xxh64(clipboard.encode("utf-8") if isinstance(clipboard, str) else clipboard).intdigest() def is_clipboard_size_within_limit( self, clipboard_content: any, type_: str = "text" @@ -215,7 +215,9 @@ def paste(self, payload: any, payload_type: str = "text"): self.reset_files_download() if payload_type == "text": - if PLATFORM == WINDOWS or PLATFORM == MACOS: + if PLATFORM == MACOS: + clipboard_monitor.write_to_pasteboard(payload, pasteboard.String) + elif PLATFORM == WINDOWS: pyperclip.copy(payload) elif PLATFORM.startswith(LINUX): if XMODE and self.is_x_clipboard_owner: diff --git a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py index fd013982..c1c3d39b 100644 --- a/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py +++ b/ClipCascade_Desktop/src/clipboard/clipboard_monitor_mac.py @@ -1,149 +1,126 @@ -from io import BytesIO +"""Poll and write NSPasteboard on AppKit's main thread only. + +Clipboard conversion and network sends run on a worker so polling never blocks +AppKit. A generation token makes queued callbacks harmless after stop/restart. +""" import logging +import queue import threading -import time + import pasteboard +from PyObjCTools import AppHelper +from utils.main_thread import on_main_thread -_clipboard_thread = None -_callback_update = None -_run = False -_first_run = False +_session = None _block_image_once = False -_pasteboard_lock = threading.Lock() _pb_writer = None +@on_main_thread def write_to_pasteboard(data, pb_type): global _pb_writer - with _pasteboard_lock: + try: if _pb_writer is None: _pb_writer = pasteboard.Pasteboard() _pb_writer.set_contents(data, pb_type) + except Exception: + logging.exception("Failed to write to pasteboard") -def _runner(enable_image_monitoring=False, enable_file_monitoring=False): - global _first_run, _block_image_once - try: - pb_text = pasteboard.Pasteboard() - if enable_image_monitoring: - pb_image_png = pasteboard.Pasteboard() - pb_image_tiff = pasteboard.Pasteboard() - if enable_file_monitoring: - pb_files = pasteboard.Pasteboard() - image_processed = False - files_processed = False - while _run: - # don't change the execution order (files,text,image or files,image,text) - - if enable_file_monitoring: - # Files - with _pasteboard_lock: - clipboard_files = pb_files.get_file_urls(diff=True) - if ( - clipboard_files is not None - and type(clipboard_files) is tuple - and len(clipboard_files) > 0 - ): - if _callback_update and not _first_run: - files_processed = True - _callback_update("files", clipboard_files) - - # Text - with _pasteboard_lock: - clipboard_text = pb_text.get_contents( - type=pasteboard.String, diff=True - ) # If True, retrieves and returns the content only if it has changed since the last call. - # This approach is efficient even in cases of frequent polling. - if ( - clipboard_text is not None - and type(clipboard_text) is str - and len(clipboard_text) > 0 - ): - if _callback_update and not _first_run and not files_processed: - _callback_update("text", clipboard_text) - - if enable_image_monitoring: - # Image (PNG) - with _pasteboard_lock: - clipboard_image_png = pb_image_png.get_contents( - type=pasteboard.PNG, diff=True - ) - if ( - clipboard_image_png is not None - and type(clipboard_image_png) is bytes - ): - if _callback_update and not _first_run and not image_processed: - image_processed = True - if _block_image_once: - _block_image_once = False - else: - if not files_processed: - _callback_update("image", clipboard_image_png) - - # Image (TIFF) - with _pasteboard_lock: - clipboard_image_tiff = pb_image_tiff.get_contents( - type=pasteboard.TIFF, diff=True - ) - if ( - clipboard_image_tiff is not None - and type(clipboard_image_tiff) is bytes - ): - if _callback_update and not _first_run and not image_processed: - image_processed = True - if _block_image_once: - _block_image_once = False - else: - if not files_processed: - _callback_update("image", clipboard_image_tiff) - - files_processed = False - image_processed = False - _first_run = False - time.sleep(0.3) # seconds - - except Exception as e: - logging.error(f"Error processing clipboard update: {e}") - - -def _start(enable_image_monitoring=False, enable_file_monitoring=False): - global _clipboard_thread, _run, _first_run - if not _clipboard_thread: - _run = True - _first_run = True - _clipboard_thread = threading.Thread( - target=_runner, - args=(enable_image_monitoring, enable_file_monitoring), - daemon=True, - ) - _clipboard_thread.start() - - -def stop(): - global _clipboard_thread, _callback_update, _run, _first_run, _block_image_once, _pb_writer - if _clipboard_thread: - _run = False - _clipboard_thread.join() # Wait for the thread to finish - _first_run = False - _clipboard_thread = None - _callback_update = None - _block_image_once = False - _pb_writer = None - logging.info("Clipboard monitor stopped") +@on_main_thread +def enable_block_image_once(): + global _block_image_once + _block_image_once = True -def wait(): - global _clipboard_thread - if _clipboard_thread: - _clipboard_thread.join() +def _deliver(session): + while not session['stop'].is_set(): + try: + kind, payload = session['queue'].get(timeout=0.3) + except queue.Empty: + continue + if session['stop'].is_set(): + break + try: + session['callback'](kind, payload) + except Exception: + logging.exception("Error processing clipboard update") -def enable_block_image_once(): +def _poll(session): global _block_image_once - _block_image_once = True + if _session is not session or session['stop'].is_set(): + return + try: + # Create and retain every native pasteboard object on the main thread. + if not session['boards']: + session['boards'] = [(kind, pasteboard.Pasteboard()) for kind in session['kinds']] + updates = {} + for kind, pb in session['boards']: + value = pb.get_file_urls(diff=True) if kind == 'files' else pb.get_contents( + type={'text': pasteboard.String, 'png': pasteboard.PNG, 'tiff': pasteboard.TIFF}[kind], + diff=True, + ) + if value: + updates[kind] = value + if not session['first']: + event = None + if 'files' in updates: + event = ('files', updates['files']) + elif 'text' in updates: + event = ('text', updates['text']) + elif 'png' in updates or 'tiff' in updates: + if _block_image_once: + _block_image_once = False + else: + event = ('image', updates.get('png', updates.get('tiff'))) + if event: + # Only the latest clipboard matters if conversion falls behind. + try: + session['queue'].get_nowait() + except queue.Empty: + pass + session['queue'].put_nowait(event) + session['first'] = False + except Exception: + logging.exception("Error polling pasteboard") + finally: + if _session is session and not session['stop'].is_set(): + AppHelper.callLater(0.3, _poll, session) + + +@on_main_thread +def on_update(callback, enable_image_monitoring=False, enable_file_monitoring=False): + global _session + if _session is not None: + _session['callback'] = callback + return + kinds = (['files'] if enable_file_monitoring else []) + ['text'] + if enable_image_monitoring: + kinds += ['png', 'tiff'] + session = dict(callback=callback, kinds=kinds, boards=[], first=True, + queue=queue.Queue(maxsize=1), stop=threading.Event()) + _session = session + worker = threading.Thread(target=_deliver, args=(session,), daemon=True) + session['worker'] = worker + worker.start() + _poll(session) + + +@on_main_thread +def stop(): + global _session, _block_image_once, _pb_writer + if _session is not None: + _session['stop'].set() + # Release native references on the main thread too; never join a worker + # from AppKit (it may be finishing a clipboard/network callback). + _session['boards'].clear() + _session = None + _block_image_once = False + _pb_writer = None -def on_update(callback, enable_image_monitoring=False, enable_file_monitoring=False): - global _callback_update - _callback_update = callback - _start(enable_image_monitoring, enable_file_monitoring) +def wait(): + session = _session + if session is not None and threading.current_thread() is not threading.main_thread(): + session['worker'].join() diff --git a/ClipCascade_Desktop/src/core/application.py b/ClipCascade_Desktop/src/core/application.py index 0424ca0d..b7d8f714 100644 --- a/ClipCascade_Desktop/src/core/application.py +++ b/ClipCascade_Desktop/src/core/application.py @@ -1,4 +1,7 @@ +import atexit +import errno import logging +import signal import sys @@ -7,6 +10,7 @@ from core.config import Config from utils.request_manager import RequestManager from utils.cipher_manager import CipherManager +from utils.update_checker import UpdateChecker, parse_version from stomp_ws.stomp_manager import STOMPManager from p2p.p2p_manager import P2PManager @@ -21,13 +25,11 @@ from cli.login import LoginForm from cli.info import CustomDialog from cli.tray import TaskbarPanel - from cli.message_box import MessageBox from cli.echo import Echo else: from gui.login import LoginForm from gui.info import CustomDialog from gui.tray import TaskbarPanel - from gui.message_box import MessageBox class Application: @@ -37,6 +39,8 @@ def __init__( data_file_path=DATA_FILE_NAME, mutex_identifier=MUTEX_NAME, ): + self._macos_shutdown_complete = False + self.update_checker = UpdateChecker(self.get_version_update_status) try: self.log_file_path = os.path.join( get_program_files_directory(), log_file_path @@ -97,28 +101,67 @@ def ensure_single_instance(self): ).mainloop() sys.exit(1) - # Create the lock file + # The kernel lock determines ownership, not the file's existence or PID. + # Keep the same file in place so all launches lock the same inode. try: self.create_lock_file() - except IOError: - run_anyway = MessageBox().askquestion( - "ClipCascade", - "Another instance of ClipCascade is already running. Do you want to run anyway?", - ) - if run_anyway == "yes": - os.remove(self.mutex_identifier) - self.create_lock_file() - else: - self.lock_file = None - sys.exit(0) + except OSError as error: + if error.errno not in (errno.EAGAIN, errno.EWOULDBLOCK): + raise + CustomDialog( + "Another instance of ClipCascade is already running.", + msg_type="warning", + ).mainloop() + 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 create_lock_file(self, path=None): if path is None: path = self.mutex_identifier if PLATFORM == MACOS or PLATFORM.startswith(LINUX): - self.lock_file = open(path, "w") - fcntl.flock(self.lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + lock_file = open(path, "a+") + try: + fcntl.flock(lock_file, fcntl.LOCK_EX | fcntl.LOCK_NB) + lock_file.seek(0) + lock_file.truncate() + lock_file.write(str(os.getpid())) + lock_file.flush() + except BaseException: + lock_file.close() + raise + self.lock_file = lock_file + + 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 + # Closing releases the kernel lock, including after a crash. Never unlink + # the file: another process may already have it open or acquire it next. + try: + self.lock_file.close() + except Exception: + pass + self.lock_file = None def authenticate_and_connect(self): # Attempt to connect with existing cookie @@ -153,6 +196,8 @@ def authenticate_and_connect(self): else lambda: sys.exit(0) ), ) + if not (PLATFORM.startswith(LINUX) and LINUX_USE_CLI_UI): + login_form.watch_updates(self.update_checker) login_form.mainloop() # wait until login form is closed raw_password = self.config.data[ "password" @@ -186,7 +231,7 @@ def authenticate_and_connect(self): self.config.data["hashed_password"] = ( self.cipher_manager.hash_password(raw_password) ) - if not self.config.data["save_password"]: + if self.config.data["cipher_enabled"] or not self.config.data["save_password"]: self.config.data["password"] = "" if display_login_success_dialog: CustomDialog( @@ -214,16 +259,17 @@ def _get_ws_manager(self): else: return self.stomp_manager - def get_version_update_status(self) -> list: + def get_version_update_status(self) -> list | None: """ Checks for a new version of the application by comparing the current version with the one available in a remote JSON file. Returns: - list: [bool, str, str, str] - [Is new version available, latest version, current version, release URL] + [Is new version available, latest version, current version, release URL], + or None when the check fails. """ try: - response = RequestManager.get(VERSION_URL) + response = RequestManager.get(VERSION_URL, timeout=(5, 10)) response_data = response.json() if PLATFORM == WINDOWS: key = "windows" @@ -235,11 +281,12 @@ def get_version_update_status(self) -> list: else: key = "linux_non_gui" - if response_data[key] != APP_VERSION: - return [True, response_data[key], APP_VERSION, RELEASE_URL] + latest_version = response_data[key] + newer = parse_version(latest_version) > parse_version(APP_VERSION) + return [newer, latest_version.strip(), APP_VERSION, RELEASE_URL] except Exception as e: logging.error(f"Error checking for new version: {e}") - return [False, "", APP_VERSION, RELEASE_URL] + return None # Unknown/offline: retain the last successful result. def get_donation_url(self) -> str: try: @@ -271,27 +318,45 @@ def banner(self): Echo(GITHUB_URL.center(53)) Echo("*" * 53) + def _shutdown_macos(self, logoff=False): + """Run on a worker while AppKit continues servicing native UI callbacks.""" + try: + if logoff: + self.logoff_and_exit() + else: + self._get_ws_manager().disconnect() + finally: + try: + # Both managers are constructed at startup, even in relay mode. + # Wait for P2P cleanup before stopping its asyncio worker. + self.p2p_manager.shutdown() + finally: + self._cleanup_lock_file() + self._macos_shutdown_complete = True + def run(self): try: self.banner() self.setup_logging() self.ensure_single_instance() self.config.load() + self.update_checker.start() self.authenticate_and_connect() self.config.save() - update_available = self.get_version_update_status() donation_url = self.get_donation_url() sys_tray = TaskbarPanel( on_connect_callback=self._get_ws_manager().manual_reconnect, on_disconnect_callback=self._get_ws_manager().disconnect, on_logoff_callback=self.logoff_and_exit, - new_version_available=update_available, + new_version_available=self.update_checker.status, github_url=GITHUB_URL, donation_url=donation_url, ws_interface=self._get_ws_manager(), config=self.config, + **({"on_shutdown_callback": self._shutdown_macos} if PLATFORM == MACOS else {}), ) + sys_tray.update_checker = self.update_checker self._get_ws_manager().set_tray_ref(sys_tray) sys_tray.run() except Exception as e: @@ -301,9 +366,11 @@ def run(self): msg + "\nCheck logs in project directory", msg_type="error" ).mainloop() finally: - self._get_ws_manager().disconnect() - 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.update_checker.stop() + if not (PLATFORM == MACOS and self._macos_shutdown_complete): + try: + self._get_ws_manager().disconnect() + except Exception: + pass + if PLATFORM == MACOS or PLATFORM.startswith(LINUX): + self._cleanup_lock_file() diff --git a/ClipCascade_Desktop/src/core/config.py b/ClipCascade_Desktop/src/core/config.py index b91a1e20..a6fe9765 100644 --- a/ClipCascade_Desktop/src/core/config.py +++ b/ClipCascade_Desktop/src/core/config.py @@ -2,10 +2,16 @@ import json import os import re +import tempfile from core.constants import * class Config: + DEVICE_PREFERENCE_FIELDS = frozenset({ + "notification", "update_notifications", "save_password", "max_clipboard_size_local_limit_bytes", + "enable_image_sharing", "enable_file_sharing", "default_file_download_location", + }) + def __init__(self, file_name=DATA_FILE_NAME): self.file_name = file_name self.data = { @@ -20,6 +26,7 @@ def __init__(self, file_name=DATA_FILE_NAME): "salt": "", "csrf_token": "", "notification": True, + "update_notifications": True, "save_password": False, "password": "", "max_clipboard_size_local_limit_bytes": None, @@ -30,21 +37,50 @@ def __init__(self, file_name=DATA_FILE_NAME): "stun_url": "", "ssl_ca_bundle": "", } + self._saved_data = self.data.copy() def save(self): """ Save data to file """ + return self._save_data(self.data.copy()) + + def save_preferences(self, updates): + """Persist only device preferences, preserving the last saved login settings.""" + if not updates.keys() <= self.DEVICE_PREFERENCE_FIELDS: + raise ValueError("Only device preferences can be saved independently") + saved = self._saved_data.copy() + saved.update(updates) + if not saved["save_password"]: + saved["password"] = "" + if self._save_data(saved): + self.data.update(updates) + return True + return False + + def _save_data(self, data): + temporary_path = None try: - temp = self.data.copy() - if self.data.get("cipher_enabled") and self.data.get("hashed_password"): + temp = data.copy() + if isinstance(temp.get("hashed_password"), bytes): temp["hashed_password"] = base64.b64encode( temp["hashed_password"] ).decode("utf-8") - with open(self.file_name, "w") as f: + with tempfile.NamedTemporaryFile( + mode="w", dir=os.path.dirname(os.path.abspath(self.file_name)), + delete=False, + ) as f: + temporary_path = f.name json.dump(temp, f, indent=4) + os.replace(temporary_path, self.file_name) + self._saved_data = data.copy() + return True except Exception as e: logging.error(f"Failed to save data: {e}") + return False + finally: + if temporary_path and os.path.exists(temporary_path): + os.unlink(temporary_path) def load(self): """ @@ -60,6 +96,7 @@ def load(self): self.data["hashed_password"] = base64.b64decode( self.data["hashed_password"] ) + self._saved_data = self.data.copy() return True except Exception as e: logging.error(f"Failed to load data: {e}") diff --git a/ClipCascade_Desktop/src/core/constants.py b/ClipCascade_Desktop/src/core/constants.py index 78c357a3..2c6e5e8b 100644 --- a/ClipCascade_Desktop/src/core/constants.py +++ b/ClipCascade_Desktop/src/core/constants.py @@ -176,7 +176,8 @@ def _strip_linux_cli_overrides(argv): # core constants -RECONNECT_WS_TIMER = 10 # seconds +RECONNECT_WS_TIMER = 10 # seconds between failed attempts, before jitter +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 +189,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 idle for 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..32c7acd4 100644 --- a/ClipCascade_Desktop/src/gui/login.py +++ b/ClipCascade_Desktop/src/gui/login.py @@ -1,11 +1,14 @@ import re +import threading +import webbrowser import tkinter as tk -from tkinter import ttk, font +from tkinter import ttk, font, filedialog, messagebox import gc import time import os from utils.window_manager import center_window +from utils.notification_manager import NotificationManager from core.config import Config from gui.info import CustomDialog from core.constants import * @@ -57,6 +60,24 @@ def _hide(self, _event=None): class LoginForm(tk.Tk): + def watch_updates(self, checker): + """Poll on Tk's UI thread; never touch Tk from the network worker.""" + def poll(): + status = checker.claim_alert(self.config.data.get("update_notifications", True)) + if status: + message = f"ClipCascade {status[1]} is available (installed: {status[2]})." + if self.focus_displayof() is not None: + if messagebox.askyesno("Update available", message + "\nOpen the download page?", parent=self): + webbrowser.open(status[3]) + else: + threading.Thread( + target=NotificationManager(self.config).notify, + args=("ClipCascade update available", message + " Use the tray menu to open the download page."), + kwargs={"preference": "update_notifications"}, daemon=True, + ).start() + self._update_poll_id = self.after(1000, poll) + self._update_poll_id = self.after(1000, poll) + def __init__( self, config: Config, @@ -87,6 +108,16 @@ def __init__( style.configure("TLabel", font=("Helvetica", 13), padding=5) style.configure("TEntry", font=("Helvetica", 13), padding=5) style.configure("TButton", font=("Helvetica", 13), padding=5) + style.configure("Path.TButton", font=("Helvetica", 11), padding=(2, 1)) + + # Draw a small trash icon without depending on platform emoji fonts. + self.clear_path_icon = tk.PhotoImage(master=self, width=12, height=14) + for rectangle in ( + (4, 0, 8, 1), (1, 2, 11, 3), + (2, 4, 3, 12), (9, 4, 10, 12), (2, 12, 10, 13), + (5, 5, 6, 10), (7, 5, 8, 10), + ): + self.clear_path_icon.put("#888888", to=rectangle) style.configure("TCheckbutton", font=("Helvetica", 13), padding=5) # Main frame with padding @@ -96,7 +127,7 @@ def __init__( # Title Label title_label = ttk.Label( main_frame, - text="Please Log In", + text="Log in", font=("Helvetica", 16, "bold"), takefocus=False, ) @@ -114,7 +145,8 @@ def __init__( self.username_entry.grid(row=0, column=1, padx=10, pady=5, sticky=tk.W + tk.E) self._add_tooltip( [user_label, self.username_entry], - "Username used to log in to your ClipCascade account.\n\n" + "Username used to log in to your ClipCascade account.\n" + "\n" "Use the same account name on all devices that should share clipboard data.", ) @@ -136,9 +168,11 @@ def __init__( self.eye_icon.bind("", self._toggle_password) self._add_tooltip( [pass_label, self.password_entry, self.eye_icon], - "Account password used for server authentication.\n\n" - "If encryption is enabled, this password is also used to derive your encryption key.\n" - "Use the same password on all devices for successful decrypt/sync.", + "Account password used for server authentication.\n" + "\n" + "When encryption is enabled, this password also derives your encryption key. Use the same password on every device.\n" + "\n" + "Note: after changing your account password, log in again on each device so their encryption keys match.", ) # Server URL @@ -168,33 +202,32 @@ def __init__( self.cipher_var = tk.BooleanVar(value=self.config.data["cipher_enabled"]) self.cipher_checkbox = ttk.Checkbutton( checks_frame, - text="Enable Encryption (recommended)", + text="Enable encryption (recommended)", variable=self.cipher_var, takefocus=False, ) self.cipher_checkbox.pack(pady=3, anchor=tk.W) self._add_tooltip( [self.cipher_checkbox], - "Enables end-to-end encryption for clipboard content (recommended).\n\n" - "When enabled, clipboard data is encrypted on-device before sending.\n" - "All devices in the same account must use the same encryption settings " - "(password, salt, and hash rounds) to decrypt data correctly.", + "Encrypts clipboard content on your device before sending it. Recommended.\n" + "\n" + "Note: all devices sharing clipboard data must use matching encryption settings, including the password, encryption salt, and key derivation iterations.", ) # Notification Checkbox self.notification_var = tk.BooleanVar(value=self.config.data["notification"]) self.notification_checkbox = ttk.Checkbutton( checks_frame, - text="Enable Notification", + text="Show connection status notifications", variable=self.notification_var, takefocus=False, ) self.notification_checkbox.pack(pady=3, anchor=tk.W) self._add_tooltip( [self.notification_checkbox], - "Shows local notifications for connection status events.\n\n" - "Useful to know when WebSocket disconnects/reconnects happen.\n" - "Turn this off if you prefer a quieter experience with fewer popups.", + "Shows local notifications when the server connection is lost or restored.\n" + "\n" + "Turn this off if you prefer fewer popups.", ) # Button frame @@ -202,17 +235,16 @@ def __init__( button_frame.pack(pady=(10, 0)) # Login Button - self.login_button = ttk.Button(button_frame, text="Login", command=self.on_login) + self.login_button = ttk.Button(button_frame, text="Log in", command=self.on_login) self.login_button.pack() # Extra Configurations Toggle Label - # macOS: show advanced fields by default - self.show_extra = PLATFORM == MACOS + self.show_extra = False self.toggle_normal_color = "#007acc" # neutral blue with strong contrast self.toggle_hover_color = "#005a9e" # slightly darker for hover feedback self.toggle_label = tk.Label( main_frame, - text="Hide Extra Config" if PLATFORM == MACOS else "Enable Extra Config", + text="Show advanced settings", fg=self.toggle_normal_color, cursor="hand2", font=font.Font(family="Helvetica", size=13, underline=True), @@ -222,8 +254,9 @@ def __init__( self.toggle_label.bind("", self._toggle_extra_config) self._add_tooltip( [self.toggle_label], - "Shows advanced settings for encryption and clipboard behavior.\n\n" - "Most users can keep defaults. Open this section only if you need custom tuning.", + "Shows advanced settings for encryption, clipboard limits, sharing, download location, and certificate trust.\n" + "\n" + "Most users can keep the defaults.", ) # hover effects for the toggle label @@ -232,10 +265,6 @@ def __init__( # Extra Configuration Frame with Scrollbar self.extra_frame_container = ttk.Frame(main_frame) - if PLATFORM == MACOS: - self.extra_frame_container.pack(fill=tk.BOTH, expand=True) - else: - self.extra_frame_container.pack_forget() # Create a canvas inside the extra_frame_container self.extra_canvas = tk.Canvas( @@ -274,32 +303,33 @@ def __init__( self.extra_frame_container.bind("", self._on_extra_container_configure) # Hash Rounds - hash_rounds_label = ttk.Label(self.extra_frame, text="Hash Rounds:") + hash_rounds_label = ttk.Label(self.extra_frame, text="Key derivation iterations:") hash_rounds_label.grid(row=0, column=0, padx=(0, 10), pady=5, sticky=tk.W) self.hash_rounds_entry = ttk.Entry(self.extra_frame, width=50, font=("Helvetica", 13)) self.hash_rounds_entry.insert(0, str(self.config.data["hash_rounds"])) self.hash_rounds_entry.grid(row=0, column=1, padx=10, pady=5, sticky=tk.W + tk.E) self._add_tooltip( [hash_rounds_label, self.hash_rounds_entry], - "Number of hash iterations used for encryption key derivation.\n\n" - "Higher value = stronger brute-force resistance, but slower processing.\n" - "Default is tuned for balance. Keep default unless you know why to change.\n" - "Must be a positive integer and must match on every device.", + "Number of iterations used to derive your encryption key. Higher values increase resistance to password guessing, but take longer.\n" + "\n" + "Default: 664937. Keep the default unless you need a different value.\n" + "\n" + "Note: must be a positive integer and must match on every device.", ) # Salt - salt_label = ttk.Label(self.extra_frame, text="Salt:") + salt_label = ttk.Label(self.extra_frame, text="Encryption salt:") salt_label.grid(row=1, column=0, padx=(0, 10), pady=5, sticky=tk.W) self.salt_entry = ttk.Entry(self.extra_frame, width=50, font=("Helvetica", 13)) self.salt_entry.insert(0, self.config.data["salt"]) self.salt_entry.grid(row=1, column=1, padx=10, pady=5, sticky=tk.W + tk.E) self._add_tooltip( [salt_label, self.salt_entry], - "Optional extra text mixed into encryption key generation.\n\n" - "It can be any combination of letters and numbers (you can also include symbols/spaces if desired).\n" - "Use the same exact salt on all devices if encryption is enabled.\n" - "If left blank, encryption still works using password + hash rounds.\n" - "Changing salt later will prevent old devices from decrypting new clipboard data.", + "Optional extra text mixed into encryption key derivation. Letters, numbers, symbols, and spaces are allowed.\n" + "\n" + "Example: myCustomSalt123\n" + "\n" + "Note: use exactly the same salt on every device, including capitalization and spaces. Leave blank to keep the default. Changing it on only one device prevents decryption between devices.", ) # Save Password Locally Checkbox @@ -317,14 +347,14 @@ def __init__( self.save_password_checkbox.grid(row=2, column=1, padx=10, pady=5, sticky=tk.W) self._add_tooltip( [save_password_label, self.save_password_checkbox], - "Stores your plain password locally for automatic login convenience.\n\n" - "Security trade-off: avoid this on shared or untrusted machines.\n" - "This only works when encryption is disabled.", + "Stores your password locally for automatic login convenience.\n" + "\n" + "Note: only works when encryption is disabled. Not recommended on shared or untrusted devices.", ) # Maximum Clipboard Size - Local Limit local_clipboard_size_label = ttk.Label( - self.extra_frame, text="Maximum Clipboard Size\nLocal Limit (in bytes):" + self.extra_frame, text="Local clipboard size limit (bytes):" ) local_clipboard_size_label.grid(row=3, column=0, padx=(0, 10), pady=5, sticky=tk.W) self.local_clipboard_size_entry = ttk.Entry( @@ -336,11 +366,13 @@ def __init__( self.local_clipboard_size_entry.grid(row=3, column=1, padx=10, pady=5, sticky=tk.W + tk.E) self._add_tooltip( [local_clipboard_size_label, self.local_clipboard_size_entry], - "Optional local safety limit for clipboard payload size (incoming and outgoing) in bytes.\n\n" - "Example: 1 MiB = 1048576 bytes.\n" - "Leave empty for no local limit.\n" - "Use a lower value if your device struggles with very large clipboard data " - "(large text/images/files). Must be a positive integer.", + "Optional local limit for incoming and outgoing clipboard content, in bytes. Must be a positive integer.\n" + "\n" + "Examples:\n" + "- 512 KiB = 524288 bytes\n" + "- 1 MiB = 1048576 bytes\n" + "\n" + "Leave blank for no local limit. Server and platform limits still apply. A smaller limit can help if large clipboard content causes problems on your device.", ) # Enable Image Sharing Checkbox @@ -357,8 +389,9 @@ def __init__( self.enable_image_sharing_checkbox.grid(row=4, column=1, padx=10, pady=5, sticky=tk.W) self._add_tooltip( [enable_image_sharing_label, self.enable_image_sharing_checkbox], - "Controls whether this device sends copied images to other devices.\n\n" - "If disabled, this device can still receive images sent from other devices.", + "Allows this device to send copied images to other devices.\n" + "\n" + "Note: turning this off does not prevent receiving images from other devices.", ) # Enable File Sharing Checkbox @@ -373,57 +406,151 @@ def __init__( self.enable_file_sharing_checkbox.grid(row=5, column=1, padx=10, pady=5, sticky=tk.W) self._add_tooltip( [enable_file_sharing_label, self.enable_file_sharing_checkbox], - "Controls whether this device sends copied/shared files to other devices.\n\n" - "If disabled, this device can still receive files sent from other devices.", + "Allows this device to send copied or shared files to other devices.\n" + "\n" + "Note: turning this off does not prevent receiving files from other devices.", ) # Default File Download Location default_file_download_location_label = ttk.Label( - self.extra_frame, text="Default File Download Location:" + self.extra_frame, text="Default download folder:" ) default_file_download_location_label.grid( row=6, column=0, padx=(0, 10), pady=5, sticky=tk.W ) + download_path_frame = ttk.Frame(self.extra_frame) + download_path_frame.grid(row=6, column=1, padx=10, pady=5, sticky=tk.EW) + download_path_frame.columnconfigure(0, weight=1) self.default_file_download_location_entry = ttk.Entry( - self.extra_frame, width=50, font=("Helvetica", 13) + download_path_frame, width=30, font=("Helvetica", 13) ) self.default_file_download_location_entry.insert( 0, self.config.data["default_file_download_location"] ) - self.default_file_download_location_entry.grid( - row=6, column=1, padx=10, pady=5, sticky=tk.W + tk.E - ) + self.default_file_download_location_entry.grid(row=0, column=0, sticky=tk.EW) + self.download_folder_browse_button = ttk.Button( + download_path_frame, + text="Browse…", + width=0, + style="Path.TButton", + command=lambda: self._browse_path( + self.default_file_download_location_entry, directory=True + ), + ) + self.download_folder_browse_button.grid(row=0, column=1, padx=(4, 0)) + self.download_folder_clear_button = ttk.Button( + download_path_frame, + image=self.clear_path_icon, + width=0, + style="Path.TButton", + command=lambda: self._clear_path(self.default_file_download_location_entry), + ) + self.download_folder_clear_button.grid(row=0, column=2, padx=(4, 0)) self._add_tooltip( - [default_file_download_location_label, self.default_file_download_location_entry], - "Optional default folder to save incoming files automatically.\n\n" + [default_file_download_location_label, self.default_file_download_location_entry, + self.download_folder_browse_button], + "Optional default folder for saving incoming files. Type a path or use Browse… to select an existing folder.\n" + "\n" "Examples:\n" "- Windows: C:\\Users\\YourName\\Downloads\n" - "- macOS/Linux: /Users/yourname/Downloads\n\n" - "Leave blank to choose a location manually when downloading files.\n" - "Path must already exist on this device.", + "- macOS: /Users/yourname/Downloads\n" + "- Linux: /home/yourname/Downloads\n" + "\n" + "Use the trash button or leave blank to choose a folder for each download.", ) # 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="Custom CA certificate bundle:") ssl_ca_label.grid(row=7, column=0, padx=(0, 10), pady=5, sticky=tk.W) + ca_path_frame = ttk.Frame(self.extra_frame) + ca_path_frame.grid(row=7, column=1, padx=10, pady=5, sticky=tk.EW) + ca_path_frame.columnconfigure(0, weight=1) self.ssl_ca_bundle_entry = ttk.Entry( - self.extra_frame, width=50, font=("Helvetica", 13) + ca_path_frame, width=30, font=("Helvetica", 13) ) self.ssl_ca_bundle_entry.insert( 0, self.config.data.get("ssl_ca_bundle") or "" ) - self.ssl_ca_bundle_entry.grid(row=7, column=1, padx=10, pady=5, sticky=tk.W + tk.E) + self.ssl_ca_bundle_entry.grid(row=0, column=0, sticky=tk.EW) + self.ca_bundle_browse_button = ttk.Button( + ca_path_frame, + text="Browse…", + width=0, + style="Path.TButton", + command=lambda: self._browse_path(self.ssl_ca_bundle_entry), + ) + self.ca_bundle_browse_button.grid(row=0, column=1, padx=(4, 0)) + self.ca_bundle_clear_button = ttk.Button( + ca_path_frame, + image=self.clear_path_icon, + width=0, + style="Path.TButton", + command=lambda: self._clear_path(self.ssl_ca_bundle_entry), + ) + self.ca_bundle_clear_button.grid(row=0, column=2, padx=(4, 0)) + self._add_tooltip( + [ssl_ca_label, self.ssl_ca_bundle_entry, self.ca_bundle_browse_button], + "Path to a PEM-encoded certificate bundle for a private or internal certificate authority. Used to verify HTTPS/WSS connections.\n" + "\n" + "Type a path or use Browse… to select a file.\n" + "\n" + "Examples:\n" + "- Windows: C:\\certs\\corp-root.pem\n" + "- macOS/Linux: /etc/ssl/corp-root.pem\n" + "\n" + "Use the trash button or leave blank, then log in, to use default certificate trust.", + ) + + self._add_tooltip( + [self.download_folder_clear_button], + "Clear the default download folder.\nChoose a folder for each download instead.", + ) self._add_tooltip( - [ssl_ca_label, self.ssl_ca_bundle_entry], - "Path to a PEM file that contains your root CA (or full chain) for HTTPS/WSS.\n\n" - "Leave empty for default verification (public CAs / OS defaults).\n" - "Use this when your server uses a certificate from a private or internal CA.\n\n" - "Example: /etc/ssl/corp-root.pem or C:\\\\certs\\\\corp-root.pem", + [self.ca_bundle_clear_button], + "Clear the custom CA certificate bundle.\nLog in to use default certificate trust.", + ) + + update_label = ttk.Label(self.extra_frame, text="Notify when an update is available:") + update_label.grid(row=8, column=0, padx=(0, 10), pady=5, sticky=tk.W) + self.update_notifications_var = tk.BooleanVar(value=self.config.data["update_notifications"]) + update_checkbox = ttk.Checkbutton( + self.extra_frame, variable=self.update_notifications_var, takefocus=False, + ) + update_checkbox.grid(row=8, column=1, padx=10, pady=5, sticky=tk.W) + self._add_tooltip( + [update_label, update_checkbox], + "Checks at startup and once a day while running.\n" + "Alerts once per new version each session. Enabled by default.", ) # Configure grid weights for extra_frame self.extra_frame.columnconfigure(1, weight=1) + # Bind after initialization so opening the form never writes defaults. + for field, variable in ( + ("notification", self.notification_var), + ("update_notifications", self.update_notifications_var), + ("save_password", self.save_password_var), + ("enable_image_sharing", self.enable_image_sharing_var), + ("enable_file_sharing", self.enable_file_sharing_var), + ): + variable.trace_add( + "write", lambda *_, key=field, var=variable: + self._save_preference(key, var.get()) + ) + self._preference_entries = [] + for field, entry in ( + ("max_clipboard_size_local_limit_bytes", self.local_clipboard_size_entry), + ("default_file_download_location", self.default_file_download_location_entry), + ): + variable = tk.StringVar(value=entry.get()) + entry.configure(textvariable=variable) + variable.trace_add( + "write", lambda *_, key=field, var=variable: + self._save_preference_entry(key, var.get()) + ) + self._preference_entries.append(variable) + # Bind mouse wheel events for scrolling self._bind_mousewheel() @@ -452,18 +579,66 @@ def __init__( self.after(400, self._macos_restore_focus) def _sync_login_tab_order(self): - """Tab cycles username → password → server → Login → [extra entries if shown] → repeat. + """Tab cycles through login fields and visible advanced entries/pickers. Checkboxes and chrome are excluded from keyboard focus.""" extra_entries = ( self.hash_rounds_entry, self.salt_entry, self.local_clipboard_size_entry, self.default_file_download_location_entry, + self.download_folder_browse_button, + self.download_folder_clear_button, self.ssl_ca_bundle_entry, + self.ca_bundle_browse_button, + self.ca_bundle_clear_button, ) for w in extra_entries: w.configure(takefocus=bool(self.show_extra)) + def _clear_path(self, entry): + # Entry deletion also updates its StringVar and saved device preference. + entry.delete(0, tk.END) + entry.focus_set() + + def _browse_path(self, entry, directory=False): + current_path = os.path.expanduser(entry.get().strip()) + initial_dir = current_path if directory else os.path.dirname(current_path) + if not os.path.isdir(initial_dir): + initial_dir = os.path.expanduser("~") + options = {"parent": self, "initialdir": initial_dir} + try: + if directory: + selected = filedialog.askdirectory( + title="Select default download folder", mustexist=True, **options + ) + else: + selected = filedialog.askopenfilename( + title="Select custom CA certificate bundle", + filetypes=( + ("PEM certificates", "*.pem"), + ("CRT certificates", "*.crt"), + ("All files", "*"), + ), + **options, + ) + except (tk.TclError, RuntimeError, OSError) as error: + CustomDialog( + f"Unable to open the path picker: {error}\n\n" + "You can still enter the path manually.", + msg_type="error", + ).mainloop() + return + if selected: + variable = entry.cget("textvariable") + if variable: + # Update once so download-folder preference saving never sees a + # temporary empty path between deleting and inserting text. + self.setvar(variable, selected) + else: + entry.delete(0, tk.END) + entry.insert(0, selected) + entry.focus_set() + def _add_tooltip(self, widgets, text): for widget in widgets: self._tooltips.append(HoverTooltip(widget, text)) @@ -496,17 +671,21 @@ def _toggle_extra_config(self, event=None): self.salt_entry, self.local_clipboard_size_entry, self.default_file_download_location_entry, + self.download_folder_browse_button, + self.download_folder_clear_button, self.ssl_ca_bundle_entry, + self.ca_bundle_browse_button, + self.ca_bundle_clear_button, ) if self.show_extra: focused = self.focus_get() if focused in extra_entries: self.login_button.focus_set() self.extra_frame_container.pack_forget() - self.toggle_label.config(text="Enable Extra Config") + self.toggle_label.config(text="Show advanced settings") else: self.extra_frame_container.pack(fill=tk.BOTH, expand=True) - self.toggle_label.config(text="Hide Extra Config") + self.toggle_label.config(text="Hide advanced settings") self.show_extra = not self.show_extra self._sync_login_tab_order() @@ -558,6 +737,20 @@ def _on_extra_container_configure(self, event): def remove_trailing_slash(entry): return re.sub(r"/+$", "", entry) + def _save_preference(self, field, value): + if not self.config.save_preferences({field: value}): + CustomDialog("Unable to save device preferences.", msg_type="error").mainloop() + + def _save_preference_entry(self, field, value): + value = value.strip() + if field == "max_clipboard_size_local_limit_bytes": + if value and not self.is_positive_integer(value): + return # Keep incomplete/invalid edits out of persistent settings. + value = int(value) if value else None + elif value and not os.path.isdir(value): + return + self._save_preference(field, value) + @staticmethod def is_positive_integer(value): if value.isdigit() and int(value) > 0: @@ -574,6 +767,7 @@ def on_login(self): ) self.config.data["cipher_enabled"] = self.cipher_var.get() self.config.data["notification"] = self.notification_var.get() + self.config.data["update_notifications"] = self.update_notifications_var.get() # extra fields @@ -581,7 +775,7 @@ def on_login(self): hash_rounds_input = self.hash_rounds_entry.get().strip() if not LoginForm.is_positive_integer(hash_rounds_input): CustomDialog( - "Invalid Input\nHash Rounds must be a positive integer.", + "Invalid Input\nKey derivation iterations must be a positive integer.", msg_type="error", ).mainloop() return # retry login @@ -597,7 +791,7 @@ def on_login(self): else: if not LoginForm.is_positive_integer(max_clipboard_size_local_limit_bytes_input): CustomDialog( - "Invalid Input\nMax Clipboard Size Local Limit must be a positive integer.", + "Invalid Input\nLocal clipboard size limit must be a positive integer.", msg_type="error", ).mainloop() return # retry login @@ -613,9 +807,9 @@ def on_login(self): if default_file_download_location == "": self.config.data["default_file_download_location"] = "" else: - if not os.path.exists(default_file_download_location): + if not os.path.isdir(default_file_download_location): CustomDialog( - "Invalid Input\nDefault File Download Location does not exist.", + "Invalid Input\nDefault download folder does not exist.", msg_type="error", ).mainloop() return # retry login @@ -625,7 +819,7 @@ def on_login(self): if ssl_ca_bundle: if not os.path.isfile(ssl_ca_bundle): CustomDialog( - "Invalid Input\nSSL CA bundle path is not a file or does not exist.", + "Invalid Input\nCustom CA certificate bundle path is not a file or does not exist.", msg_type="error", ).mainloop() return @@ -643,6 +837,9 @@ def on_quit(self): self.close() def close(self): + if getattr(self, "_update_poll_id", None): + self.after_cancel(self._update_poll_id) + self._update_poll_id = None self.destroy() gc.collect() time.sleep(0.5) diff --git a/ClipCascade_Desktop/src/gui/macos_tray.py b/ClipCascade_Desktop/src/gui/macos_tray.py new file mode 100644 index 00000000..e0317556 --- /dev/null +++ b/ClipCascade_Desktop/src/gui/macos_tray.py @@ -0,0 +1,93 @@ +"""macOS event handling and layout for the pinned pystray backend.""" + +import logging +import signal + +import objc +from AppKit import NSWorkspace, NSWorkspaceWillPowerOffNotification +from Foundation import NSAppleEventManager, NSObject +from PyObjCTools import AppHelper, MachSignals +from pystray import Icon + +from utils.main_thread import on_main_thread + + +class QuitEventHandler(NSObject): + @objc.signature(b"v@:@@") + def handleQuitEvent_withReplyEvent_(self, event, reply): + self.callback("quit Apple event") + + @objc.signature(b"v@:@") + def workspaceWillPowerOff_(self, notification): + self.callback("workspace logout/shutdown notification") + + +class MacOSTrayIcon(Icon): + def run_with_shutdown(self, on_quit): + # Tk installs a quit Apple-event handler that queues work in Tcl. + # With AppKit driving the loop, handle that event directly instead. + manager = NSAppleEventManager.sharedAppleEventManager() + event_class = int.from_bytes(b"aevt", "big") # kCoreEventClass + event_id = int.from_bytes(b"quit", "big") # kAEQuitApplication + handler = QuitEventHandler.alloc().init() + + @on_main_thread + def request_shutdown(source): + logging.info("macOS received %s", source) + on_quit() + + handler.callback = request_shutdown + workspace = NSWorkspace.sharedWorkspace() + notifications = workspace.notificationCenter() + active = True + previous_sigint = MachSignals.getsignal(signal.SIGINT) + + def handle_sigint(signum): + request_shutdown("SIGINT (Ctrl+C)") + + def install_handler(): + if active: + manager.setEventHandler_andSelector_forEventClass_andEventID_( + handler, b"handleQuitEvent:withReplyEvent:", event_class, event_id + ) + # pystray installs its SIGINT handler just before entering the + # loop. It calls Tk's terminate(), queuing an unserviced Tcl + # event. MachSignals wakes AppKit and starts cleanup directly. + MachSignals.signal(signal.SIGINT, handle_sigint) + + # NSApplication installs its own handlers during launch. Register after + # the native loop starts so launch cannot replace our cleanup handler. + # Also observe the workspace's logout/shutdown signal directly: it must + # not depend on Tk processing its queued quit event or on mouse input. + notifications.addObserver_selector_name_object_( + handler, b"workspaceWillPowerOff:", NSWorkspaceWillPowerOffNotification, workspace + ) + AppHelper.callAfter(install_handler) + try: + self.run() + finally: + active = False + # The backend skips restoring SIGINT when its handler was replaced; + # restore the mapping from before run() ourselves in that case. + if MachSignals.getsignal(signal.SIGINT) is handle_sigint: + MachSignals.signal(signal.SIGINT, previous_sigint) + notifications.removeObserver_(handler) + manager.removeEventHandlerForEventClass_andEventID_(event_class, event_id) + + @on_main_thread + def _show(self): + # pystray's default setup calls this from a worker thread. + super()._show() + + @on_main_thread + def _update_icon(self): + super()._update_icon() + + def _assert_image(self): + super()._assert_image() + # pystray 0.19.5 leaves NSImage's point size implicit and uses a + # variable-width status item. Use a square slot matching the image + # so the artwork stays centered without extra horizontal padding. + thickness = self._status_bar.thickness() + self._icon_image.setSize_((thickness, thickness)) + self._status_item.setLength_(thickness) diff --git a/ClipCascade_Desktop/src/gui/tray.py b/ClipCascade_Desktop/src/gui/tray.py index d16a25a1..92f83a3e 100644 --- a/ClipCascade_Desktop/src/gui/tray.py +++ b/ClipCascade_Desktop/src/gui/tray.py @@ -8,13 +8,24 @@ from pystray import Icon, MenuItem as item, Menu from PIL import Image, ImageDraw +from utils.main_thread import on_main_thread +from utils.notification_manager import NotificationManager from core.config import Config from gui.info import CustomDialog from core.constants import * +if PLATFORM == MACOS: + from PyObjCTools import AppHelper + from gui.macos_tray import MacOSTrayIcon as TrayIcon + from clipboard import clipboard_monitor_mac +else: + TrayIcon = Icon + if PLATFORM != WINDOWS: import subprocess +MACOS_SHUTDOWN_TIMEOUT = 15 + class TaskbarPanel: def __init__( @@ -27,6 +38,7 @@ def __init__( donation_url: str = None, ws_interface=None, # type= interfaces.ws_interface.WSInterface config: Config = None, + on_shutdown_callback: callable = None, ): self.on_connect_callback = on_connect_callback self.on_disconnect_callback = on_disconnect_callback @@ -36,11 +48,15 @@ def __init__( self.donation_url = donation_url self.ws_interface = ws_interface self.config = config + self.on_shutdown_callback = on_shutdown_callback + self._shutdown_done = threading.Event() + self._shutdown_watchdog = None self.is_disconnecting = False self.disconnecting_items = None self.is_file_download_enabled = False self.file_download_items = None + self._stopped = threading.Event() self.previous_stats: str = "" self.previous_stats_items = None @@ -59,7 +75,7 @@ def __init__( self.is_connected = True # Create the tray icon - self.icon = Icon( + self.icon = TrayIcon( "ClipCascade", self.create_clipboard_icon(), menu=self.create_menu() ) @@ -68,7 +84,56 @@ def __init__( self.update_stats() # Start the stats update thread def run(self): - self.icon.run() + if PLATFORM != MACOS: + self.icon.run() + return + + # PyObjC callbacks re-entering Tk's macOS mainloop can corrupt its + # saved Python thread state. Let AppKit own the tray's event loop. + try: + self.icon.run_with_shutdown(lambda: self._on_quit(None, None)) + finally: + self._stopped.set() + try: + self.icon.stop() + finally: + self.root.destroy() + + def _begin_macos_shutdown(self, logoff=False): + if self._stopped.is_set(): + return + self._stopped.set() + logging.info("macOS shutdown requested (logoff=%s)", logoff) + # Leave the watchdog armed after the UI closes, including while Python + # waits for remaining threads. Normal process exit kills this daemon. + self._shutdown_watchdog = threading.Timer(MACOS_SHUTDOWN_TIMEOUT, os._exit, args=(1,)) + self._shutdown_watchdog.daemon = True + self._shutdown_watchdog.start() + + # Release native pasteboard objects on the main thread before workers + # start closing connections. Their later stop() calls are idempotent. + clipboard_monitor_mac.stop() + + def cleanup(): + try: + if self.on_shutdown_callback: + self.on_shutdown_callback(logoff) + except Exception: + logging.exception("macOS shutdown cleanup failed") + finally: + self._shutdown_done.set() + + threading.Thread(target=cleanup, name="MacOSShutdown", daemon=True).start() + self._poll_macos_shutdown() + + def _poll_macos_shutdown(self): + # Network cleanup may dispatch pasteboard work back to the main thread. + # Keep servicing AppKit until it finishes; never join it here. + if self._shutdown_done.is_set(): + logging.info("macOS shutdown cleanup finished") + self.icon.stop() + else: + AppHelper.callLater(0.05, self._poll_macos_shutdown) def _create_clipboard_base_image(self): """Shared clipboard artwork for normal tray icon and file-download badge variant.""" @@ -140,6 +205,12 @@ def create_menu(self, item_: tuple = None): Menu: The menu for the tray icon. """ # Menu items + version_suffix = ( + " (latest)" + if self.new_version_available is not None + and self.new_version_available[1] == APP_VERSION + else "" + ) menu_items = [ Menu.SEPARATOR, item("🗒️ Open Logs", self._open_logs), @@ -150,6 +221,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}{version_suffix}", None, enabled=False), + Menu.SEPARATOR, item("🔒 Logoff and Quit", self._on_logoff), item("❌ Quit", self._on_quit), ] @@ -200,20 +273,35 @@ def create_menu(self, item_: tuple = None): return Menu(*menu_items) + @on_main_thread def update_menu(self, item_: tuple = None): - self.icon.menu = self.create_menu(item_=item_) + if not self._stopped.is_set(): + self.icon.menu = self.create_menu(item_=item_) def update_stats(self): threading.Thread(target=self._update_stats_thread, daemon=True).start() def _update_stats_thread(self): - while True: + while not self._stopped.is_set(): + checker = getattr(self, "update_checker", None) + if checker: + if checker.status is not None and checker.status != self.new_version_available: + self.new_version_available = checker.status + self.update_menu() + status = checker.claim_alert(self.config.data.get("update_notifications", True)) + if status: + # The desktop client has no visible main window in tray mode. + NotificationManager(self.config).notify( + "ClipCascade update available", + f"Version {status[1]} is available. Open the tray menu to update.", + preference="update_notifications", + ) current_stats = self.ws_interface.get_stats() if current_stats is not None and self.previous_stats != current_stats: self.previous_stats = current_stats self.previous_stats_items = (current_stats, 0, None) self.update_menu() - time.sleep(1) # Sleep for 1 second + self._stopped.wait(1) @staticmethod def open_webbrowser(url): @@ -318,6 +406,7 @@ def _open_program_location(self, icon, item): msg_type="error", ).mainloop() + @on_main_thread def enable_files_download(self, files): self.is_file_download_enabled = True self.icon.icon = self.create_clipboard_icon_with_dot() @@ -328,6 +417,7 @@ def enable_files_download(self, files): ) self.update_menu() + @on_main_thread def disable_files_download(self): self.is_file_download_enabled = False self.file_download_items = None @@ -382,6 +472,10 @@ def _on_download(self, icon, item, files): ).mainloop() def _on_logoff(self, icon, item): + if PLATFORM == MACOS: + self._begin_macos_shutdown(logoff=True) + return + self._stopped.set() try: if self.on_logoff_callback: self.on_logoff_callback() @@ -393,5 +487,9 @@ def _on_logoff(self, icon, item): ).mainloop() def _on_quit(self, icon, item): + if PLATFORM == MACOS: + self._begin_macos_shutdown() + return + self._stopped.set() self.icon.stop() self.root.quit() diff --git a/ClipCascade_Desktop/src/p2p/p2p_manager.py b/ClipCascade_Desktop/src/p2p/p2p_manager.py index da99d71e..88303694 100644 --- a/ClipCascade_Desktop/src/p2p/p2p_manager.py +++ b/ClipCascade_Desktop/src/p2p/p2p_manager.py @@ -9,6 +9,7 @@ from typing import Dict, List, Optional from core.config import Config from interfaces.ws_interface import WSInterface +from utils.reconnect import ReconnectMixin from utils.cipher_manager import CipherManager from clipboard.clipboard_manager import ClipboardManager from utils.notification_manager import NotificationManager @@ -29,7 +30,7 @@ else: from gui.tray import TaskbarPanel -class P2PManager(WSInterface): +class P2PManager(ReconnectMixin, WSInterface): def __init__(self, config: Config, is_login_phase=True): self.config = config self.clipboard_manager = ClipboardManager(self.config) @@ -42,12 +43,16 @@ def __init__(self, config: Config, is_login_phase=True): self.is_connected = False self.disconnected = False self.is_auto_reconnecting = False - self._suppress_auto_reconnect_once = False self.is_clipboard_monitoring_on = False + self._init_reconnect() + self._disconnect_future = None # 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 -> {"last_received_at": float, "fragments": list[str]} + # Refresh the timestamp on each fragment 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 @@ -89,6 +94,7 @@ def _dc_heartbeat_tick(self) -> None: self._dc_heartbeat_handle = None if self.disconnected or self._p2p_shutting_down or not self.is_connected: return + self._prune_stale_receiving_fragments() ping = json.dumps({"_cc_keepalive": True}) for ch in list(self.data_channels.values()): try: @@ -122,11 +128,13 @@ def _sync_live_connections_count(self): """Set live_connections from open data channels (avoids +/- drift and negatives).""" with self._live_connections_lock: self.live_connections = sum( - 1 for ch in self.data_channels.values() if getattr(ch, "readyState", "") == "open" + 1 for ch in list(self.data_channels.values()) if getattr(ch, "readyState", "") == "open" ) def get_stats(self) -> str: self._sync_live_connections_count() + if not self.is_connected: + return "Disconnected" if self.disconnected else "Reconnecting…" stats = "📊" with self._live_connections_lock: n = self.live_connections @@ -142,20 +150,24 @@ def get_total_timeout(self): Returns the total timeout value in milliseconds.""" return (RECONNECT_WS_TIMER * 1000) + WEBSOCKET_TIMEOUT - def connect(self) -> tuple[bool, str]: + def _connect_once(self) -> tuple[bool, str]: """ Connects to the P2P websocket signaling server. """ try: + if self._disconnect_future is not None: + self._disconnect_future.result(timeout=90) + self._disconnect_future = None if self.ws_client is not None: if self.is_connected: return True, "" - try: - self.ws_client.close() - except Exception: - pass - self.ws_client = None + self.ws_close() + # Cleanup must finish before starting the socket: doing this in + # on_open can consume the entire WebSocket handshake timeout. + self.schedule_task(self._prepare_reconnect_bootstrap()).result(timeout=90) + if self.disconnected: + return False, "Websocket disconnected" self.ws_client = websocket.WebSocketApp( url=self.config.data["websocket_url"], header={"Cookie": RequestManager.format_cookie(self.config.data["cookie"])}, @@ -183,21 +195,22 @@ def connect(self) -> tuple[bool, str]: self.is_clipboard_monitoring_on = True self.clipboard_manager.on_copy(self.send) - if not self.is_login_phase: - return True, "" - total_ms = 0 while self.is_connected is False: + if self.disconnected: + self.ws_close() + return False, "Websocket disconnected" time.sleep(0.25) total_ms += 250 if 0 < WEBSOCKET_TIMEOUT < total_ms: - self.ws_client.close() + self.ws_close() raise TimeoutError( f"Connection to {self.config.data['websocket_url']} timed out" ) return True, "Websocket connected" except Exception as e: + self.is_connected = False msg = f"Failed to connect websocket: {e}" logging.error(msg) try: @@ -207,11 +220,9 @@ def connect(self) -> tuple[bool, str]: return False, msg def _on_ws_close(self, ws, *args): - self.ws_client = None - self.is_connected = False - if self._suppress_auto_reconnect_once: - self._suppress_auto_reconnect_once = False + if ws is not self.ws_client: return + self.is_connected = False # Auto Reconnect if not self.is_login_phase and not self.disconnected: self.is_auto_reconnecting = True @@ -221,18 +232,11 @@ def _on_ws_close(self, ws, *args): message="Check your internet connection. Retrying...", ) self.first_conn_lost = False - time.sleep(RECONNECT_WS_TIMER) # seconds - self.connect() - - def manual_reconnect(self): - if not self.is_auto_reconnecting: - self.disconnected = False - self._suppress_auto_reconnect_once = self.ws_client is not None - self.ws_close() - self.is_connected = False - self.connect() + self._start_reconnect() def _on_ws_message(self, ws, message): + if ws is not self.ws_client: + return self.schedule_task(self._on_ws_message_async(message)) async def _on_ws_message_async(self, message): @@ -274,26 +278,18 @@ async def _prepare_reconnect_bootstrap(self): await self._cleanup_peer_connections(clear_bootstrap_state=False) def _on_ws_open(self, ws, *args): + if ws is not self.ws_client: + return try: if self.disconnected: self.disconnect() return - # Tear down WebRTC after any signaling reconnect so PEER_LIST recreates PCs. - try: - fut = asyncio.run_coroutine_threadsafe( - self._prepare_reconnect_bootstrap(), self.loop - ) - # Upper bound: N peers * P2P_PC_CLOSE_TIMEOUT + margin; - fut.result(timeout=90) - except Exception as e: - logging.warning( - "P2P peer cleanup on signaling open: %s", - e if str(e) else type(e).__name__, - ) - + if ws is not self.ws_client or self.disconnected: + return 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 @@ -320,11 +316,44 @@ def ws_send(self, data: dict): def ws_close(self): if self.ws_client is not None: - self.ws_client.close() + ws = self.ws_client self.ws_client = None + ws.close() def disconnect(self): - self.schedule_task(self._disconnect()) + self._stop_reconnect() + self.is_connected = False + self._disconnect_future = self.schedule_task(self._disconnect()) + + def shutdown(self, timeout=5): + """Finish pending disconnect work and stop the loop at application exit. + + Called from the shutdown worker, never from AppKit or the asyncio loop. + Ordinary tray Disconnect keeps the loop alive for reconnection. + """ + self._stop_reconnect() + deadline = time.monotonic() + timeout + try: + if self._disconnect_future is not None: + self._disconnect_future.result(timeout=timeout * 0.8) + finally: + try: + # Signaling/ICE tasks can still be alive after peers close. + # Cancel them on their own loop before releasing the loop. + self.schedule_task(self._cancel_shutdown_tasks()).result( + timeout=max(0, deadline - time.monotonic()) + ) + finally: + self.loop.call_soon_threadsafe(self.loop.stop) + self.loop_thread.join(timeout=max(0, deadline - time.monotonic())) + if not self.loop_thread.is_alive(): + self.loop.close() + + async def _cancel_shutdown_tasks(self): + tasks = [task for task in asyncio.all_tasks() if task is not asyncio.current_task()] + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) async def _disconnect(self): """ @@ -334,7 +363,6 @@ async def _disconnect(self): """ try: self.clipboard_manager.previous_clipboard_hash = 0 - self.disconnected = True self.first_conn_lost = True # Close the websocket @@ -357,6 +385,7 @@ async def _disconnect(self): async def _cleanup_peer_connections(self, clear_bootstrap_state: bool = True): self._p2p_shutting_down = True self._cancel_dc_heartbeat() + self.reset_receiving_fragments() try: try: for dc in list(self.data_channels.values()): @@ -769,6 +798,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 idle for 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("last_received_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 +843,18 @@ 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 + entry["last_received_at"] = time.monotonic() 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 +865,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"]] = { + "last_received_at": time.monotonic(), + "fragments": [""] * metadata["totalFragments"], + } + self.receiving_fragments[metadata["id"]]["fragments"][ + metadata["index"] + ] = payload return if self.config.data["cipher_enabled"]: @@ -843,12 +901,17 @@ def fragment_string(s: str, fragment_size: int = FRAGMENT_SIZE) -> list[str]: Returns: list[str]: A list of string fragments. """ - # Encode the string to bytes to accurately split by byte size - s_bytes = s.encode("utf-8") - fragments = [ - s_bytes[i : i + fragment_size].decode("utf-8", errors="ignore") - for i in range(0, len(s_bytes), fragment_size) - ] + if fragment_size < 4: + raise ValueError("fragment_size must allow a complete UTF-8 character (at least 4 bytes)") + encoded = s.encode("utf-8") + fragments = [] + start = 0 + while start < len(encoded): + end = min(start + fragment_size, len(encoded)) + while end < len(encoded) and encoded[end] & 0xC0 == 0x80: + end -= 1 + fragments.append(encoded[start:end].decode("utf-8")) + start = end return fragments @staticmethod diff --git a/ClipCascade_Desktop/src/packaging/macos_self_test.py b/ClipCascade_Desktop/src/packaging/macos_self_test.py new file mode 100644 index 00000000..7442fe2a --- /dev/null +++ b/ClipCascade_Desktop/src/packaging/macos_self_test.py @@ -0,0 +1,31 @@ +"""Run a headless native-dependency check before the normal frozen entry point.""" + +import sys + +if sys.argv[1:] == ['--self-test']: + import asyncio + import ssl + + import aiortc + import av + import cryptography + from aiortc import RTCCertificate, RTCPeerConnection + from cryptography.hazmat.backends.openssl.backend import backend + + async def check_peer_connection(): + # Exercise certificate generation and DTLS setup, beyond import checks. + certificate = RTCCertificate.generateCertificate() + assert certificate.getFingerprints(), 'No certificate fingerprints' + peer = RTCPeerConnection() + try: + peer.createDataChannel('packaging-self-test') + await peer.createOffer() + finally: + await peer.close() + + asyncio.run(check_peer_connection()) + print('Python OpenSSL:', ssl.OPENSSL_VERSION, flush=True) + print('cryptography:', cryptography.__version__, backend.openssl_version_text(), flush=True) + print('aiortc:', aiortc.__version__, 'PyAV:', av.__version__, flush=True) + print('ClipCascade packaged dependency self-test passed', flush=True) + sys.exit(0) diff --git a/ClipCascade_Desktop/src/pyproject.toml b/ClipCascade_Desktop/src/pyproject.toml index 28281f93..025b9c49 100644 --- a/ClipCascade_Desktop/src/pyproject.toml +++ b/ClipCascade_Desktop/src/pyproject.toml @@ -32,19 +32,23 @@ classifiers = [ ] dependencies = [ - "Pillow>=10.4.0", + "Pillow>=10.4.0; python_version < '3.14'", + "Pillow==12.0.0; python_version >= '3.14'", "plyer==2.1.0", "pycryptodome==3.20.0", "pystray==0.19.5", "Requests==2.32.3", "websocket_client==1.8.0", - "xxhash==3.5.0", + "xxhash>=3.5.0,<5", "beautifulsoup4==4.12.3", - "aiortc==1.10.0", + "aiortc==1.10.0; python_version < '3.14'", + "aiortc==1.14.0; python_version >= '3.14'", + "av>=16.0.0,<17; python_version >= '3.14'", # Platform-specific dependencies "pyfiglet==1.0.2; sys_platform == 'linux'", "pyperclip==1.8.2; sys_platform == 'win32' or sys_platform == 'darwin'", "pywin32==306; sys_platform == 'win32'", + "pyobjc-framework-Cocoa>=10; sys_platform == 'darwin'", "certifi; sys_platform == 'darwin'", "pyobjus==1.2.3; sys_platform == 'darwin'", "pasteboard==0.4.0; sys_platform == 'darwin'", diff --git a/ClipCascade_Desktop/src/requirements_linux.txt b/ClipCascade_Desktop/src/requirements_linux.txt index b613c300..23135c59 100644 --- a/ClipCascade_Desktop/src/requirements_linux.txt +++ b/ClipCascade_Desktop/src/requirements_linux.txt @@ -1,10 +1,13 @@ -Pillow==10.4.0 +Pillow==10.4.0; python_version < "3.14" +Pillow==12.0.0; python_version >= "3.14" plyer==2.1.0 pycryptodome==3.20.0 pystray==0.19.5 Requests==2.32.3 websocket_client==1.8.0 -xxhash==3.5.0 +xxhash>=3.5.0,<5 pyfiglet==1.0.2 beautifulsoup4==4.12.3 -aiortc==1.10.0 +aiortc==1.10.0; python_version < "3.14" +aiortc==1.14.0; python_version >= "3.14" +av>=16.0.0,<17; python_version >= "3.14" diff --git a/ClipCascade_Desktop/src/requirements_linux_cli.txt b/ClipCascade_Desktop/src/requirements_linux_cli.txt index dc63be30..b9567f0d 100644 --- a/ClipCascade_Desktop/src/requirements_linux_cli.txt +++ b/ClipCascade_Desktop/src/requirements_linux_cli.txt @@ -2,6 +2,8 @@ pycryptodome==3.20.0 pyfiglet==1.0.2 Requests==2.32.3 websocket_client==1.8.0 -xxhash==3.5.0 +xxhash>=3.5.0,<5 beautifulsoup4==4.12.3 -aiortc==1.10.0 +aiortc==1.10.0; python_version < "3.14" +aiortc==1.14.0; python_version >= "3.14" +av>=16.0.0,<17; python_version >= "3.14" diff --git a/ClipCascade_Desktop/src/requirements_linux_gui.txt b/ClipCascade_Desktop/src/requirements_linux_gui.txt index de0c639c..65849644 100644 --- a/ClipCascade_Desktop/src/requirements_linux_gui.txt +++ b/ClipCascade_Desktop/src/requirements_linux_gui.txt @@ -1,9 +1,12 @@ -Pillow==10.4.0 +Pillow==10.4.0; python_version < "3.14" +Pillow==12.0.0; python_version >= "3.14" plyer==2.1.0 pycryptodome==3.20.0 pystray==0.19.5 Requests==2.32.3 websocket_client==1.8.0 -xxhash==3.5.0 +xxhash>=3.5.0,<5 beautifulsoup4==4.12.3 -aiortc==1.10.0 +aiortc==1.10.0; python_version < "3.14" +aiortc==1.14.0; python_version >= "3.14" +av>=16.0.0,<17; python_version >= "3.14" diff --git a/ClipCascade_Desktop/src/requirements_mac.txt b/ClipCascade_Desktop/src/requirements_mac.txt index 494bc5de..45c97fc5 100644 --- a/ClipCascade_Desktop/src/requirements_mac.txt +++ b/ClipCascade_Desktop/src/requirements_mac.txt @@ -1,12 +1,19 @@ +# Official cryptography wheels include a private, statically linked OpenSSL. +# A source build can collide with Python's libssl.3.dylib after freezing. +--only-binary cryptography certifi pyobjus==1.2.3 pasteboard==0.4.0 -Pillow==11.0.0 +Pillow==11.0.0; python_version < "3.14" +Pillow==12.0.0; python_version >= "3.14" pycryptodome==3.20.0 pyperclip==1.8.2 pystray==0.19.5 Requests==2.32.3 websocket_client==1.8.0 -xxhash==3.5.0 +xxhash>=3.5.0,<5 beautifulsoup4==4.12.3 -aiortc==1.10.0 +aiortc==1.10.0; python_version < "3.14" +aiortc==1.14.0; python_version >= "3.14" +av>=16.0.0,<17; python_version >= "3.14" +pyobjc-framework-Cocoa>=10 diff --git a/ClipCascade_Desktop/src/requirements_win.txt b/ClipCascade_Desktop/src/requirements_win.txt index ee4e967c..fecc2b7d 100644 --- a/ClipCascade_Desktop/src/requirements_win.txt +++ b/ClipCascade_Desktop/src/requirements_win.txt @@ -1,4 +1,5 @@ -Pillow==10.4.0 +Pillow==10.4.0; python_version < "3.14" +Pillow==12.0.0; python_version >= "3.14" plyer==2.1.0 pycryptodome==3.20.0 pyperclip==1.8.2 @@ -6,6 +7,8 @@ pystray==0.19.5 pywin32==306 Requests==2.32.3 websocket_client==1.8.0 -xxhash==3.5.0 +xxhash>=3.5.0,<5 beautifulsoup4==4.12.3 -aiortc==1.10.0 +aiortc==1.10.0; python_version < "3.14" +aiortc==1.14.0; python_version >= "3.14" +av>=16.0.0,<17; python_version >= "3.14" diff --git a/ClipCascade_Desktop/src/stomp_ws/client.py b/ClipCascade_Desktop/src/stomp_ws/client.py index e09f32fe..ec0b41c5 100644 --- a/ClipCascade_Desktop/src/stomp_ws/client.py +++ b/ClipCascade_Desktop/src/stomp_ws/client.py @@ -1,5 +1,5 @@ import time -from threading import Thread +from threading import Event, Thread from .frame import Frame import websocket @@ -24,6 +24,9 @@ def __init__(self, url, headers={}, on_close_callback=None, sslopt=None): self.on_close_callback = on_close_callback self.opened = False + self._closed = Event() + self._ready = Event() + self._error = None self.connected = False @@ -34,33 +37,38 @@ def __init__(self, url, headers={}, on_close_callback=None, sslopt=None): self.errorCallback = None def _connect(self, timeout=0): + # Detect half-open sockets after sleep/network loss even when no + # clipboard traffic is being sent. + kwargs = {"ping_interval": 25, "ping_timeout": 20} if self._ws_sslopt: - thread = Thread( - target=lambda: self.ws.run_forever(sslopt=self._ws_sslopt) - ) - else: - thread = Thread(target=self.ws.run_forever) + kwargs["sslopt"] = self._ws_sslopt + thread = Thread(target=self.ws.run_forever, kwargs=kwargs) thread.daemon = True thread.start() total_ms = 0 while self.opened is False: - time.sleep(0.25) + if self._closed.wait(0.25): + raise ConnectionError(self._error or "WebSocket closed during connection") total_ms += 250 if 0 < timeout < total_ms: + self.disconnect() raise TimeoutError(f"Connection to {self.url} timed out") def _on_open(self, ws_app, *args): + if self._closed.is_set(): + ws_app.close() + return self.opened = True def _on_close(self, ws_app, *args): - self.connected = False + self._clean_up() logging.debug("Whoops! Lost connection to " + self.ws.url) if self.on_close_callback is not None: self.on_close_callback() - self._clean_up() def _on_error(self, ws_app, error, *args): + self._error = str(error) logging.debug(error) def _on_message(self, ws_app, message, *args): @@ -72,19 +80,27 @@ 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 logging.debug("connected to server " + self.url) if self._connectCallback is not None: _results.append(self._connectCallback(frame)) + self.connected = True + self._ready.set() 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: @@ -107,6 +123,9 @@ def nack(headers): elif frame.command == "RECEIPT": pass elif frame.command == "ERROR": + self._error = frame.body or "STOMP connection rejected" + self._clean_up() + self.ws.close() if self.errorCallback is not None: _results.append(self.errorCallback(frame)) else: @@ -148,6 +167,16 @@ def connect( self.errorCallback = errorCallback self._transmit("CONNECT", headers) + deadline = time.monotonic() + timeout / 1000 if timeout > 0 else None + while not self._ready.wait(0.05): + if self._closed.is_set(): + self.disconnect() + raise ConnectionError(self._error or "STOMP connection closed") + if deadline is not None and time.monotonic() >= deadline: + self.disconnect() + raise TimeoutError(f"STOMP handshake with {self.url} timed out") + if self._closed.is_set(): + raise ConnectionError(self._error or "STOMP connection closed") def disconnect(self, disconnectCallback=None, headers=None): if headers is None: @@ -163,6 +192,8 @@ def disconnect(self, disconnectCallback=None, headers=None): def _clean_up(self): self.connected = False + self.opened = False + self._closed.set() def send(self, destination, headers=None, body=None): if headers is None: diff --git a/ClipCascade_Desktop/src/stomp_ws/frame.py b/ClipCascade_Desktop/src/stomp_ws/frame.py index 4327558a..127657a8 100644 --- a/ClipCascade_Desktop/src/stomp_ws/frame.py +++ b/ClipCascade_Desktop/src/stomp_ws/frame.py @@ -1,4 +1,4 @@ -from urllib.parse import urlparse +import re Byte = {"LF": "\x0A", "NULL": "\x00"} @@ -11,40 +11,47 @@ def __init__(self, command, headers, body): self.body = "" if body is None else body def __str__(self): + # STOMP content-length counts UTF-8 bytes, not Python characters. + headers = dict(self.headers) + headers["content-length"] = str(len(self.body.encode("utf-8"))) lines = [self.command] - skipContentLength = "content-length" in self.headers - if skipContentLength: - del self.headers["content-length"] - - for name in self.headers: - value = self.headers[name] - lines.append("" + name + ":" + value) - - if self.body is not None and not skipContentLength: - lines.append("content-length:" + str(len(self.body))) - - lines.append(Byte["LF"] + self.body) - return Byte["LF"].join(lines) + lines.extend(f"{name}:{value}" for name, value in headers.items()) + return Byte["LF"].join(lines) + "\n\n" + self.body @staticmethod def unmarshall_single(data): - lines = data.split(Byte["LF"]) - - command = lines[0].strip() - 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 - - # set body to None if there is no body - body = None if lines[i + 1] == Byte["NULL"] else lines[i + 1][:-1] - - return Frame(command, headers, body) + """Parse one complete frame without truncating multiline or UTF-8 bodies.""" + try: + if isinstance(data, str): + data = data.encode("utf-8") + if not isinstance(data, bytes): + return None + # CRLF is allowed for header line endings, but body bytes are opaque. + separator = re.search(b"\r?\n\r?\n", data) + if separator is None: + return None + lines = data[:separator.start()].decode("utf-8").splitlines() + if not lines or not lines[0].strip(): + return None + command = lines[0].strip() + headers = {} + for line in lines[1:]: + key, value = line.split(":", 1) + headers.setdefault(key, value) + body = data[separator.end():] + if "content-length" in headers: + length = int(headers["content-length"]) + if length < 0 or len(body) <= length or body[length:length + 1] != b"\x00": + return None + body = body[:length] + else: + end = body.find(b"\x00") + if end < 0: + return None + body = body[:end] + return Frame(command, headers, body.decode("utf-8")) + except (ValueError, TypeError, UnicodeError): + 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..39999bdb 100644 --- a/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py +++ b/ClipCascade_Desktop/src/stomp_ws/stomp_manager.py @@ -1,9 +1,9 @@ import json import logging -import time from interfaces.ws_interface import WSInterface +from utils.reconnect import ReconnectMixin from stomp_ws.client import Client from core.config import Config from utils.cipher_manager import CipherManager @@ -19,7 +19,7 @@ from gui.tray import TaskbarPanel -class STOMPManager(WSInterface): +class STOMPManager(ReconnectMixin, WSInterface): def __init__(self, config: Config, is_login_phase=True): self.config = config self.clipboard_manager = ClipboardManager(self.config) @@ -32,6 +32,7 @@ def __init__(self, config: Config, is_login_phase=True): self.is_connected = False self.disconnected = False self.is_auto_reconnecting = False + self._init_reconnect() def set_tray_ref(self, sys_tray: TaskbarPanel): """ @@ -46,25 +47,28 @@ def get_total_timeout(self): return (RECONNECT_WS_TIMER * 1000) + WEBSOCKET_TIMEOUT def get_stats(self): - return None + return "Connected" if self.is_connected else ("Disconnected" if self.disconnected else "Reconnecting…") - def connect(self) -> tuple[bool, str]: + def _connect_once(self) -> tuple[bool, str]: try: if self.is_connected: return True, "" - self.client = Client( + if self.client is not None: + self.client.disconnect() + client = Client( self.config.data["websocket_url"], headers={ "Cookie": RequestManager.format_cookie( self.config.data["cookie"] ) }, - on_close_callback=self._on_close, + on_close_callback=lambda: self._on_close(client), sslopt=websocket_sslopt_for_config(self.config), ) - self.client.connect( + self.client = client + client.connect( timeout=WEBSOCKET_TIMEOUT, - connectCallback=lambda _: self.client.subscribe( # receive event + connectCallback=lambda _: client.subscribe( # receive event destination=SUBSCRIPTION_DESTINATION, callback=self._receive, ), @@ -75,7 +79,10 @@ def connect(self) -> tuple[bool, str]: # logging.info("Websocket connected") self.is_connected = True + if not client.connected: + raise ConnectionError("STOMP connection closed during handshake") self.is_auto_reconnecting = False + self._reconnect_attempts = 0 if not self.first_conn_lost: self.first_conn_lost = True self.notification_manager.notify( @@ -87,11 +94,19 @@ def connect(self) -> tuple[bool, str]: self.clipboard_manager.on_copy(self.send) return True, "Websocket connected" except Exception as e: + self.is_connected = False + if self.client is not None: + try: + self.client.disconnect() + except Exception: + logging.exception("Failed to close unsuccessful STOMP connection") msg = f"Failed to connect websocket: {e}" logging.error(msg) return False, msg - def _on_close(self): + def _on_close(self, client=None): + if client is not None and client is not self.client: + return self.is_connected = False # Auto Reconnect if not self.is_login_phase and not self.disconnected: @@ -102,8 +117,7 @@ def _on_close(self): message="Check your internet connection. Retrying...", ) self.first_conn_lost = False - time.sleep(RECONNECT_WS_TIMER) # seconds - self.connect() + self._start_reconnect() def send(self, payload: str, payload_type: str = "text"): try: @@ -140,12 +154,9 @@ def _receive(self, frame: any) -> str: except Exception as e: logging.error(f"Failed to receive data: {e}") - def manual_reconnect(self): - if not self.is_auto_reconnecting: - self.disconnected = False - self.connect() - def disconnect(self): + self._stop_reconnect() + self.is_connected = False try: self.clipboard_manager.previous_clipboard_hash = 0 self.disconnected = True diff --git a/ClipCascade_Desktop/src/utils/main_thread.py b/ClipCascade_Desktop/src/utils/main_thread.py new file mode 100644 index 00000000..e13ad044 --- /dev/null +++ b/ClipCascade_Desktop/src/utils/main_thread.py @@ -0,0 +1,18 @@ +"""Dispatch AppKit operations without blocking a network worker or the main loop.""" +from functools import wraps +import threading + +from core.constants import MACOS, PLATFORM + +if PLATFORM == MACOS: + from PyObjCTools import AppHelper + + +def on_main_thread(func): + @wraps(func) + def wrapped(*args, **kwargs): + if PLATFORM == MACOS and threading.current_thread() is not threading.main_thread(): + AppHelper.callAfter(func, *args, **kwargs) + return None + return func(*args, **kwargs) + return wrapped diff --git a/ClipCascade_Desktop/src/utils/notification_manager.py b/ClipCascade_Desktop/src/utils/notification_manager.py index 89cf6361..dfd53b73 100644 --- a/ClipCascade_Desktop/src/utils/notification_manager.py +++ b/ClipCascade_Desktop/src/utils/notification_manager.py @@ -16,8 +16,9 @@ class NotificationManager: def __init__(self, config: Config): self.config = config - def notify(self, title: str, message: str, app_name: str = APP_NAME, timeout=10): - if self.config.data["notification"]: # Check if notifications are enabled + def notify(self, title: str, message: str, app_name: str = APP_NAME, timeout=10, + preference="notification"): + if self.config.data.get(preference, True): try: if PLATFORM == WINDOWS or ( PLATFORM.startswith(LINUX) and not LINUX_USE_CLI_UI @@ -35,8 +36,12 @@ def notify(self, title: str, message: str, app_name: str = APP_NAME, timeout=10) [ "osascript", "-e", - f'display notification "{message}" with title "{title}"', - ] + "on run argv\n" + "display notification (item 2 of argv) with title (item 1 of argv)\n" + "end run", + title, message, + ], + timeout=10, ) except Exception as e: logging.error(f"Failed to handle notification: {e}") diff --git a/ClipCascade_Desktop/src/utils/reconnect.py b/ClipCascade_Desktop/src/utils/reconnect.py new file mode 100644 index 00000000..0369e981 --- /dev/null +++ b/ClipCascade_Desktop/src/utils/reconnect.py @@ -0,0 +1,84 @@ +"""One cancellable retry worker per connection manager.""" +import logging +import random +import threading + +from core.constants import ( + RECONNECT_WS_TIMER, RECONNECT_WS_JITTER_RATIO, +) + + +class ReconnectMixin: + def _init_reconnect(self): + self._connect_lock = threading.Lock() + self._retry_lock = threading.Lock() + self._retry_wake = threading.Event() + self._retry_thread = None + self._reconnect_attempts = 0 + + def connect(self): + # A close callback can arrive while the caller is still connecting. + # Its retry worker must wait until that attempt has finished cleaning up. + with self._connect_lock: + if self.disconnected: + return False, "Websocket disconnected" + return self._connect_once() + + def _next_reconnect_delay(self): + if self._reconnect_attempts == 0: + return 0.0 + # Keep retries responsive throughout an outage, spreading clients with jitter. + base = float(RECONNECT_WS_TIMER) + jitter = base * RECONNECT_WS_JITTER_RATIO + return max(1.0, base + random.uniform(-jitter, jitter)) + + def _start_reconnect(self, immediate=False): + with self._retry_lock: + if self.disconnected or self.is_login_phase: + return + if self._retry_thread is not None: + if immediate: + self._reconnect_attempts = 0 + self._retry_wake.set() + return + self._retry_wake.clear() + self.is_auto_reconnecting = True + self._retry_thread = threading.Thread(target=self._retry_loop, daemon=True) + self._retry_thread.start() + + def _retry_loop(self): + try: + while not self.disconnected and not self.is_connected: + with self._retry_lock: + delay = self._next_reconnect_delay() + attempt = self._reconnect_attempts + 1 + logging.info("WebSocket reconnect attempt %d in %.1fs", attempt, delay) + self._retry_wake.wait(delay) + with self._retry_lock: + self._retry_wake.clear() + if self.disconnected or self.is_connected: + break + self._reconnect_attempts += 1 + try: + self.connect() + except Exception: + logging.exception("WebSocket reconnect attempt failed") + finally: + with self._retry_lock: + self._retry_thread = None + self.is_auto_reconnecting = False + # Cover a close arriving as the successful worker was exiting. + if not self.disconnected and not self.is_connected: + self._start_reconnect() + + def _stop_reconnect(self): + with self._retry_lock: + self.disconnected = True + self.is_auto_reconnecting = False + self._retry_wake.set() + + def manual_reconnect(self): + with self._retry_lock: + self.disconnected = False + self._reconnect_attempts = 0 + self._start_reconnect(immediate=True) diff --git a/ClipCascade_Desktop/src/utils/request_manager.py b/ClipCascade_Desktop/src/utils/request_manager.py index 6918974e..f4d82df0 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 = { @@ -173,12 +181,12 @@ def get_csrf_token(self) -> str: return "" @staticmethod - def get(url: str, headers: dict = None, verify=True) -> requests.Response: + def get(url: str, headers: dict = None, verify=True, timeout=None) -> requests.Response: """ A generic GET mapper for handling GET requests. """ try: - response = requests.get(url, headers=headers, verify=verify) + response = requests.get(url, headers=headers, verify=verify, timeout=timeout) response.raise_for_status() # Will raise an HTTPError if the HTTP request returned an unsuccessful status code return response except Exception as e: diff --git a/ClipCascade_Desktop/src/utils/ssl_helper.py b/ClipCascade_Desktop/src/utils/ssl_helper.py index 482aae16..eccb4d17 100644 --- a/ClipCascade_Desktop/src/utils/ssl_helper.py +++ b/ClipCascade_Desktop/src/utils/ssl_helper.py @@ -7,6 +7,12 @@ if PLATFORM == MACOS: import certifi + # Read while the bundle still exists. macOS may purge PyInstaller's temporary + # extraction directory during a long-running session; contexts built later + # must not reopen that file. Keep custom CA paths explicit and fail closed. + with open(certifi.where(), encoding="ascii") as _ca_file: + _MACOS_CA_DATA = _ca_file.read() + def requests_verify_arg(config) -> Union[bool, str]: """Argument for requests' verify= when calling the user's server.""" @@ -27,6 +33,6 @@ def websocket_sslopt_for_config(config) -> Optional[Dict[str, Any]]: ctx = ssl.create_default_context(cafile=path) return {"context": ctx} if PLATFORM == MACOS: - ctx = ssl.create_default_context(cafile=certifi.where()) + ctx = ssl.create_default_context(cadata=_MACOS_CA_DATA) return {"context": ctx} return None diff --git a/ClipCascade_Desktop/src/utils/update_checker.py b/ClipCascade_Desktop/src/utils/update_checker.py new file mode 100644 index 00000000..44704e17 --- /dev/null +++ b/ClipCascade_Desktop/src/utils/update_checker.py @@ -0,0 +1,65 @@ +"""One immediate check, daily refreshes, and session-scoped update alerts.""" +import logging +import re +import threading +import time + + +CHECK_INTERVAL = 24 * 60 * 60 +RETRY_INTERVAL = 60 * 60 + + +def parse_version(value): + # Published client versions are stable major.minor.patch releases. + if not isinstance(value, str) or not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", value.strip()): + raise ValueError("Invalid release version") + return tuple(int(part) for part in value.strip().split(".")) + + +class UpdateChecker: + def __init__(self, fetch_status): + self.fetch_status = fetch_status + self.status = None + self._lock = threading.Lock() + self._alerted = set() + self._stopped = threading.Event() + self._thread = None + + def start(self): + if self._thread is None: + self._thread = threading.Thread(target=self._run, daemon=True, name="update-checker") + self._thread.start() + + def stop(self): + self._stopped.set() + + def claim_alert(self, enabled): + """UI callers claim only after deciding how to present an alert.""" + with self._lock: + if not enabled or not self.status or not self.status[0]: + return None + version = self.status[1] + if version in self._alerted: + return None + self._alerted.add(version) + return self.status.copy() + + def _run(self): + while not self._stopped.is_set(): + interval = self._check_once() + # Short waits also recover promptly after the computer wakes from sleep. + deadline = time.time() + interval + while not self._stopped.wait(min(60, max(0, deadline - time.time()))): + if time.time() >= deadline: + break + + def _check_once(self): + try: + status = self.fetch_status() + if status is not None: + with self._lock: + self.status = status + return CHECK_INTERVAL + except Exception: + logging.exception("Update check failed") + return RETRY_INTERVAL diff --git a/ClipCascade_Mobile/src/App.js b/ClipCascade_Mobile/src/App.js index 72aec4ba..46bc6343 100644 --- a/ClipCascade_Mobile/src/App.js +++ b/ClipCascade_Mobile/src/App.js @@ -1,5 +1,6 @@ import { PermissionsAndroid, + AppState, Image, StyleSheet, Text, @@ -13,6 +14,7 @@ import { Alert, SafeAreaView, StatusBar, + useColorScheme, } from 'react-native'; import { useEffect, useState, useRef } from 'react'; @@ -22,7 +24,13 @@ import CheckBox from '@react-native-community/checkbox'; import notifee from '@notifee/react-native'; import { pbkdf2 } from '@react-native-module/pbkdf2'; import { Buffer } from 'buffer'; -import { pickDirectory, isCancel } from '@react-native-documents/picker'; +import { + pick, + pickDirectory, + errorCodes, + isErrorWithCode, +} from '@react-native-documents/picker'; + import { sha3_512 } from 'js-sha3'; import { DOMParser } from 'react-native-html-parser'; @@ -33,6 +41,20 @@ import { clearAsyncStorage, } from './AsyncStorageManagement'; import StartForegroundService from './StartForegroundService'; +import {APP_VERSION} from './AppVersion'; +import { + checkForUpdates, presentUpdate, setUpdateFocused, subscribeToUpdates, + RELEASE_URL, UPDATE_NOTIFICATION_ID, +} from './UpdateChecker'; +import { + DEVICE_PREFERENCE_FIELDS, + isValidClipboardLimit, + saveDevicePreference, + waitForPreferenceWrites, +} from './DevicePreferences'; + +const isCancel = error => + isErrorWithCode(error) && error.code === errorCodes.OPERATION_CANCELED; /* * These files are part of the ClipCascade project. @@ -48,6 +70,8 @@ import StartForegroundService from './StartForegroundService'; * (file) android\app\src\main\java\com\clipcascade\NativeBridgeModule.kt * (file) android\app\src\main\java\com\clipcascade\NativeBridgePackage.kt * (file) android\app\src\main\java\com\clipcascade\ScheduleService.kt + * (file) android/app/src/main/java/com/clipcascade/ServerTls.kt + * (file) android/app/src/main/java/com/clipcascade/ServerTlsModule.kt * (file) android\app\src\main\AndroidManifest.xml * (folder) android\app\src\main\res\ * (file) android\app\build.gradle @@ -57,16 +81,37 @@ import StartForegroundService from './StartForegroundService'; * (file) HeadlessTask.js * (file) index.js * (file) StartForegroundService.js + * (file) DevicePreferences.js + * (file) AppVersion.js + * (file) UpdateChecker.js */ -// App version -const APP_VERSION = '3.2.0'; - // Main App +function InfoLabel({style, label, info}) { + const infoColor = useColorScheme() === 'dark' ? '#64b5f6' : '#005ea8'; + return ( + Alert.alert( + label.replace(/:$/, ''), + info, + [{text: 'OK'}], + {cancelable: true}, + )}> + {label}{' '}? + + ); +} + export default function App() { const { NativeBridgeModule } = NativeModules; const isMountedRef = useRef(true); + const loginInProgressRef = useRef(false); + const [isLoggingIn, setIsLoggingIn] = useState(false); const [newVersionAvailable, setNewVersionAvailable] = useState([false, '']); const [donateUrl, setDonateUrl] = useState(null); @@ -83,18 +128,40 @@ export default function App() { const VALIDATE_URL = '/validate-session'; const WEBSOCKET_ENDPOINT_P2P = '/p2psignaling'; const STUN_URL = '/stun-url'; - const VERSION_URL = - 'https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json'; const GITHUB_URL = 'https://github.com/Sathvik-Rao/ClipCascade'; - const RELEASE_URL = - 'https://github.com/Sathvik-Rao/ClipCascade/releases/latest'; const APP_NAME = 'ClipCascade'; const HELP_URL = `${GITHUB_URL}/blob/main/README.md`; const METADATA_URL = 'https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/metadata.json'; - // Request permissions for notifications - PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS); + useEffect(() => { + setUpdateFocused(true); + PermissionsAndroid.request(PermissionsAndroid.PERMISSIONS.POST_NOTIFICATIONS) + .then(() => presentUpdate()).catch(console.warn); + const unsubscribe = subscribeToUpdates(setNewVersionAvailable); + // Check immediately on UI launch, sharing any in-flight service request. + checkForUpdates({force: true}); + const timer = setInterval(() => checkForUpdates(), 60000); + const state = AppState.addEventListener('change', value => { + if (value === 'active') { + setUpdateFocused(true); + checkForUpdates(); + presentUpdate(); + } + }); + const focus = AppState.addEventListener('focus', () => { + setUpdateFocused(true); + presentUpdate(); + }); + const blur = AppState.addEventListener('blur', () => setUpdateFocused(false)); + return () => { + unsubscribe(); + clearInterval(timer); + state.remove(); + focus.remove(); + blur.remove(); + }; + }, []); const fetchTimeout = async (input, init, timeout_ms = FETCH_TIMEOUT) => { try { @@ -110,6 +177,8 @@ export default function App() { const [data, setData] = useState({ cipher_enabled: 'true', server_url: 'http://localhost:8080', + ssl_ca_bundle: '', + ssl_ca_bundle_name: '', websocket_url: '', username: '', hashed_password: '', @@ -121,6 +190,7 @@ export default function App() { max_clipboard_size_local_limit_bytes: '', relaunch_on_boot: 'false', enable_websocket_status_notification: 'false', + update_notifications: 'true', enable_periodic_checks: 'true', enable_image_sharing: 'true', enable_file_sharing: 'true', @@ -214,6 +284,10 @@ export default function App() { // get data from async storage and initialize data hook let data_s = await getAsyncStorage(); setData(data_s); + await NativeModules.ServerTlsModule.configure( + data_s.server_url, + data_s.ssl_ca_bundle, + ); // get foreground service status from work manager let foregroundServiceStoppedRunning = await getDataFromAsyncStorage( @@ -292,20 +366,6 @@ export default function App() { } } - // check for new version - try { - const response = await fetchTimeout(VERSION_URL); - if (!response.ok) { - throw new Error('Network response was not ok'); - } - const data = await response.json(); - if (data && data.android !== APP_VERSION) { - setNewVersionAvailable([true, data.android]); - } - } catch (e) { - // Silent catch - } - try { const response = await fetchTimeout(METADATA_URL); if (!response.ok) { @@ -761,19 +821,87 @@ export default function App() { // Function to handle input changes in the login form const handleInputChange = (field, value) => { + if (loginInProgressRef.current) { + return; + } try { setData(prevData => ({ ...prevData, [field]: value, })); + if ( + DEVICE_PREFERENCE_FIELDS.has(field) && + (field !== 'max_clipboard_size_local_limit_bytes' || + isValidClipboardLimit(value)) + ) { + saveDevicePreference(field, value) + .then(async () => { + if (field === 'enable_periodic_checks') { + if (value === 'true') { + await NativeBridgeModule.startWorkManager(); + } else { + await NativeBridgeModule.stopWorkManager(); + } + } + if ( + field === 'enable_websocket_status_notification' && + value === 'false' + ) { + await notifee.cancelNotification( + 'ClipCascade_WebSocket_Status_Notification_Id', + ); + } + if (field === 'update_notifications' && value === 'false') { + await notifee.cancelNotification(UPDATE_NOTIFICATION_ID); + } + if (field === 'update_notifications' && value === 'true') { + await presentUpdate(); + } + }) + .catch(e => + setLoginStatusMessage('❌ Unable to save preference: ' + e), + ); + } } catch (e) { setLoginStatusMessage('❌ Error: ' + e); } }; + const importCaBundle = async () => { + try { + const [file] = await pick({ allowMultiSelection: false, type: ['*/*'] }); + const pem = await NativeModules.ServerTlsModule.importBundle(file.uri); + setData(previous => ({ + ...previous, + ssl_ca_bundle: pem, + ssl_ca_bundle_name: file.name || 'Imported CA bundle', + })); + setLoginStatusMessage('CA bundle imported. Log in to save and use it.'); + } catch (error) { + if (!isCancel(error)) { + setLoginStatusMessage('❌ ' + error); + } + } + }; + // Function to handle login button press const handleLogin = async (pass = null, data_s_c = null) => { + if (loginInProgressRef.current) { + return; + } + loginInProgressRef.current = true; + setIsLoggingIn(true); try { + if ( + !isValidClipboardLimit( + (data_s_c || data).max_clipboard_size_local_limit_bytes, + ) + ) { + throw new Error( + 'Max Clipboard Size Local Limit must be a positive integer or empty.', + ); + } + await waitForPreferenceWrites(); setLoginStatusMessage('⌛ Please wait...'); // clear cookies if any @@ -798,6 +926,11 @@ export default function App() { // remove trailing slashes in server_url data_s.server_url = data_s.server_url.replace(/\/+$/, ''); + await NativeModules.ServerTlsModule.configure( + data_s.server_url, + data_s.ssl_ca_bundle, + ); + let iteration = 0; let loginResult; do { @@ -814,12 +947,17 @@ export default function App() { // Stop periodic checks work manager notification (if disabled) if (data_s.enable_periodic_checks === 'false') { - NativeBridgeModule.stopWorkManager(); + await NativeBridgeModule.stopWorkManager(); } // Save password in async storage - if (data_s.save_password === 'true') { + if ( + data_s.save_password === 'true' && + data_s.cipher_enabled !== 'true' + ) { await setDataInAsyncStorage('password', password_s); + } else { + await setDataInAsyncStorage('password', ''); } // Remove password from memory setPassword(''); @@ -839,6 +977,9 @@ export default function App() { } } catch (e) { setLoginStatusMessage('❌ Error: ' + e); + } finally { + loginInProgressRef.current = false; + setIsLoggingIn(false); } }; @@ -922,6 +1063,7 @@ export default function App() { Username: handleInputChange('username', text)} @@ -931,6 +1073,7 @@ export default function App() { Password: setPassword(text)} @@ -939,8 +1082,13 @@ export default function App() { /> - Server URL: + @@ -950,8 +1098,13 @@ export default function App() { /> - Enable Encryption (recommended): + handleInputChange('cipher_enabled', String(newValue)) @@ -959,6 +1112,24 @@ export default function App() { /> + + Show connection status notifications: + + handleInputChange( + 'enable_websocket_status_notification', + String(newValue), + ) + } + /> + + {/* Display login status message */} {loginStatusMessage !== '' && ( {loginStatusMessage} @@ -967,11 +1138,12 @@ export default function App() { {/* Login Button */} { handleLogin(null, null); }} > - Login + Log in {/* Toggle Extra Config as Text */} @@ -979,7 +1151,9 @@ export default function App() { onPress={() => setShowExtraConfig(!showExtraConfig)} > - {showExtraConfig ? 'Hide Extra Config' : 'Enable Extra Config'} + {showExtraConfig + ? 'Hide advanced settings' + : 'Show advanced settings'} @@ -987,8 +1161,13 @@ export default function App() { {showExtraConfig && ( <> - Hash Rounds: + @@ -1000,8 +1179,13 @@ export default function App() { /> - Salt: + handleInputChange('salt', text)} @@ -1009,11 +1193,9 @@ export default function App() { /> - - Store Password Locally (not recommended; only works if - encryption is disabled): - + Store Password Locally (not recommended; only works if encryption is disabled): handleInputChange('save_password', String(newValue)) @@ -1021,10 +1203,13 @@ export default function App() { /> - - Maximum Clipboard Size Local Limit (in bytes): - + @@ -1038,11 +1223,13 @@ export default function App() { /> - - Run on system startup (disable if the READ_LOGS permission is - granted): - + handleInputChange('relaunch_on_boot', String(newValue)) @@ -1050,26 +1237,13 @@ export default function App() { /> - - Enable WebSocket Status Notification: - - - handleInputChange( - 'enable_websocket_status_notification', - String(newValue), - ) - } + - - - Enable Periodic Checks: handleInputChange( @@ -1082,6 +1256,7 @@ export default function App() { Enable Image Sharing: handleInputChange('enable_image_sharing', String(newValue)) @@ -1091,12 +1266,69 @@ export default function App() { Enable File Sharing: handleInputChange('enable_file_sharing', String(newValue)) } /> + + + + + {data.ssl_ca_bundle_name || 'Default system certificates'} + + + + + {data.ssl_ca_bundle ? 'Replace' : 'Import'} + + + {data.ssl_ca_bundle !== '' && ( + + setData(previous => ({ + ...previous, + ssl_ca_bundle: '', + ssl_ca_bundle_name: '', + })) + } + > + Remove + + )} + + + + handleInputChange('update_notifications', String(value))} + /> + )} {/* Footer */} @@ -1122,6 +1354,10 @@ export default function App() { )} + + v{APP_VERSION} + {newVersionAvailable[1] === APP_VERSION ? ' (latest)' : ''} + )} @@ -1371,6 +1607,10 @@ export default function App() { HOMEPAGE + + v{APP_VERSION} + {newVersionAvailable[1] === APP_VERSION ? ' (latest)' : ''} + )} @@ -1408,10 +1648,42 @@ const styles = StyleSheet.create({ alignItems: 'center', marginBottom: 10, }, + labelInfoMark: { + fontSize: 12, + fontWeight: 'bold', + }, label: { flex: 1, fontSize: 16, }, + caDetails: { + flex: 1, + paddingRight: 12, + }, + caLabel: { + fontSize: 16, + }, + caFileName: { + fontSize: 13, + color: '#777', + marginTop: 3, + }, + caButton: { + minHeight: 44, + paddingHorizontal: 12, + justifyContent: 'center', + alignItems: 'center', + borderWidth: 1, + borderColor: '#5081ab', + borderRadius: 5, + }, + caButtonText: { + color: '#5081ab', + fontSize: 14, + }, + caRemoveButton: { + marginLeft: 8, + }, input: { flex: 2, borderWidth: 1, @@ -1491,4 +1763,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/AppVersion.js b/ClipCascade_Mobile/src/AppVersion.js new file mode 100644 index 00000000..70606b6a --- /dev/null +++ b/ClipCascade_Mobile/src/AppVersion.js @@ -0,0 +1,2 @@ +// Shared by the app screen and headless clipboard service; patched by release CI. +export const APP_VERSION = '3.2.0'; diff --git a/ClipCascade_Mobile/src/DevicePreferences.js b/ClipCascade_Mobile/src/DevicePreferences.js new file mode 100644 index 00000000..5fa08ef9 --- /dev/null +++ b/ClipCascade_Mobile/src/DevicePreferences.js @@ -0,0 +1,49 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +export const DEVICE_PREFERENCE_FIELDS = new Set([ + 'save_password', + 'max_clipboard_size_local_limit_bytes', + 'relaunch_on_boot', + 'enable_websocket_status_notification', + 'update_notifications', + 'enable_periodic_checks', + 'enable_image_sharing', + 'enable_file_sharing', +]); + +export const isValidClipboardLimit = value => + value === '' || + (/^[0-9]+$/.test(value) && + Number.isSafeInteger(Number(value)) && + Number(value) > 0); + +// Serialize changes so a slower earlier write cannot overwrite a newer toggle. +let pendingWrites = Promise.resolve(); + +export const saveDevicePreference = (field, value) => { + if (!DEVICE_PREFERENCE_FIELDS.has(field)) { + return Promise.reject( + new Error('Only device preferences can be autosaved'), + ); + } + if ( + field === 'max_clipboard_size_local_limit_bytes' + ? !isValidClipboardLimit(value) + : value !== 'true' && value !== 'false' + ) { + return Promise.reject(new Error('Invalid device preference')); + } + + pendingWrites = pendingWrites + .catch(() => {}) + .then(async () => { + const entries = [[field, JSON.stringify(value)]]; + if (field === 'save_password' && value === 'false') { + entries.push(['password', JSON.stringify('')]); + } + await AsyncStorage.multiSet(entries); + }); + return pendingWrites; +}; + +export const waitForPreferenceWrites = () => pendingWrites; diff --git a/ClipCascade_Mobile/src/StartForegroundService.js b/ClipCascade_Mobile/src/StartForegroundService.js index c26c2dd6..71c5a7df 100644 --- a/ClipCascade_Mobile/src/StartForegroundService.js +++ b/ClipCascade_Mobile/src/StartForegroundService.js @@ -17,6 +17,7 @@ import { RTCSessionDescription, } from 'react-native-webrtc'; import Clipboard from '@react-native-clipboard/clipboard'; +import {checkForUpdates} from './UpdateChecker'; import { setDataInAsyncStorage, @@ -39,12 +40,19 @@ 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 idle for 60s + const STOMP_DEACTIVATE_TIMEOUT_MS = 5000; // bound the STOMP deactivate() wait // forground service notifee.registerForegroundService(notification => { return new Promise(async () => { try { const { NativeBridgeModule } = NativeModules; + // Restore trust before either WebSocket mode starts, including headless boot. + await NativeModules.ServerTlsModule.configure( + await getDataFromAsyncStorage('server_url'), + (await getDataFromAsyncStorage('ssl_ca_bundle')) || '', + ); const textEncoder = new TextEncoder(); const textDecoder = new TextDecoder(); @@ -146,11 +154,23 @@ module.exports = async (inputData = null) => { // fragment string into chunks const fragmentString = async (str, fragmentSize) => { + if (!Number.isInteger(fragmentSize) || fragmentSize < 4) { + throw new Error( + 'fragmentSize must be an integer of at least 4 bytes', + ); + } const bytes = textEncoder.encode(str); // convert to UTF-8 bytes const fragments = []; - for (let i = 0; i < bytes.length; i += fragmentSize) { - const chunk = bytes.slice(i, i + fragmentSize); + let start = 0; + while (start < bytes.length) { + let end = Math.min(start + fragmentSize, bytes.length); + // Move the boundary back to the start of any split UTF-8 character. + while (end < bytes.length && (bytes[end] & 0xc0) === 0x80) { + end -= 1; + } + const chunk = bytes.slice(start, end); fragments.push(textDecoder.decode(chunk)); + start = end; } return fragments; }; @@ -598,9 +618,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 +684,24 @@ module.exports = async (inputData = null) => { await resetP2PMsg(); }; + // Drop partial fragment buffers idle for 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.lastReceivedAt === 'number' + && (now - entry.lastReceivedAt) > 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 +1093,24 @@ 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; + entry.lastReceivedAt = Date.now(); // 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 +1125,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] = { + lastReceivedAt: Date.now(), + fragments: Array(metadata.totalFragments).fill(''), + }; + receivingFragments[metadata.id].fragments[metadata.index] = cb; return; } } @@ -1482,6 +1530,10 @@ module.exports = async (inputData = null) => { break; } + // The shared checker gates requests to once a day and deduplicates + // concurrent screen/service checks. Never delay clipboard polling. + checkForUpdates(); + if (isP2PStatusMsgChanged) { isP2PStatusMsgChanged = false; await setDataInAsyncStorage( diff --git a/ClipCascade_Mobile/src/UpdateChecker.js b/ClipCascade_Mobile/src/UpdateChecker.js new file mode 100644 index 00000000..63aaeb5f --- /dev/null +++ b/ClipCascade_Mobile/src/UpdateChecker.js @@ -0,0 +1,146 @@ +import {Alert, AppState, Linking} from 'react-native'; +import notifee, {AndroidImportance} from '@notifee/react-native'; +import {getDataFromAsyncStorage} from './AsyncStorageManagement'; +import {APP_VERSION} from './AppVersion'; + +export const UPDATE_CHECK_INTERVAL = 24 * 60 * 60 * 1000; +const RETRY_INTERVAL = 60 * 60 * 1000; +const VERSION_URL = + 'https://raw.githubusercontent.com/Sathvik-Rao/ClipCascade/main/version.json'; +export const RELEASE_URL = + 'https://github.com/Sathvik-Rao/ClipCascade/releases/latest'; +export const UPDATE_NOTIFICATION_ID = 'ClipCascade_Update'; + +export const parseVersion = value => { + if (typeof value !== 'string' || !/^[0-9]+\.[0-9]+\.[0-9]+$/.test(value.trim())) { + throw new Error('Invalid release version'); + } + const parts = value.trim().split('.').map(Number); + if (!parts.every(Number.isSafeInteger)) { + throw new Error('Invalid release version'); + } + return parts; +}; + +export const isNewerVersion = (latest, current) => { + const remote = parseVersion(latest); + const installed = parseVersion(current); + for (let i = 0; i < remote.length; i++) { + if (remote[i] !== installed[i]) { + return remote[i] > installed[i]; + } + } + return false; +}; + +// One checker per JS runtime, shared by the screen and foreground service. +let nextCheckAt = 0; +let inFlight = null; +let status = [false, '']; +let alertInFlight = false; +let popupOpen = false; +let focused = true; +const alertedVersions = new Set(); +const listeners = new Set(); + +export const subscribeToUpdates = listener => { + listeners.add(listener); + listener(status); + return () => listeners.delete(listener); +}; + +export const presentUpdate = async () => { + const version = status[1]; + if (!status[0] || alertInFlight || alertedVersions.has(version)) { + return; + } + alertInFlight = true; + try { + if ((await getDataFromAsyncStorage('update_notifications')) === 'false') { + return; + } + // Read state after storage access: the app may have moved into the background. + if (listeners.size && AppState.currentState === 'active' && focused) { + if (popupOpen) { + return; + } + const dismiss = () => { popupOpen = false; }; + Alert.alert( + 'Update available', + `ClipCascade ${version} is available (installed: ${APP_VERSION}).`, + [ + {text: 'Later', style: 'cancel', onPress: dismiss}, + {text: 'Open download page', onPress: () => { + dismiss(); + Linking.openURL(RELEASE_URL).catch(console.warn); + }}, + ], + {cancelable: true, onDismiss: dismiss}, + ); + popupOpen = true; + } else { + // Do not consume the alert if Android notification permission is denied. + const settings = await notifee.getNotificationSettings(); + if (settings.authorizationStatus < 1) { + return; + } + const channelId = await notifee.createChannel({ + id: 'clipcascade_updates', name: 'App updates', + importance: AndroidImportance.DEFAULT, + }); + if ((await notifee.getChannel(channelId))?.blocked) { + return; + } + await notifee.displayNotification({ + id: UPDATE_NOTIFICATION_ID, + title: 'ClipCascade update available', + body: `Version ${version} is available. Tap to open ClipCascade.`, + android: { + channelId, smallIcon: 'ic_small_icon', + pressAction: {id: 'default'}, + }, + }); + } + alertedVersions.add(version); + } catch (error) { + console.warn('Unable to show update alert:', error); + } finally { + alertInFlight = false; + } +}; + +export const setUpdateFocused = value => { + focused = value; +}; + +export const checkForUpdates = ({force = false} = {}) => { + if (inFlight) { + return inFlight; + } + if (!force && Date.now() < nextCheckAt) { + return Promise.resolve(status); + } + inFlight = (async () => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), 10000); + try { + const response = await fetch(VERSION_URL, {signal: controller.signal}); + if (!response.ok) { + throw new Error(`Update check HTTP ${response.status}`); + } + const manifest = await response.json(); + const newer = isNewerVersion(manifest.android, APP_VERSION); + status = [newer, manifest.android.trim()]; + nextCheckAt = Date.now() + UPDATE_CHECK_INTERVAL; + listeners.forEach(listener => listener(status)); + } catch (error) { + // Preserve the last good result when offline or the manifest is invalid. + nextCheckAt = Date.now() + RETRY_INTERVAL; + } finally { + clearTimeout(timer); + } + await presentUpdate(); + return status; + })().finally(() => { inFlight = null; }); + return inFlight; +}; diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt index 76d26039..04a579cd 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainActivity.kt @@ -2,6 +2,7 @@ package com.clipcascade import android.content.Intent +import android.content.Context import android.os.Bundle import com.facebook.react.ReactActivity import com.facebook.react.ReactActivityDelegate @@ -16,7 +17,6 @@ import androidx.work.WorkManager import androidx.work.PeriodicWorkRequestBuilder import java.util.concurrent.TimeUnit import androidx.work.ExistingPeriodicWorkPolicy -import androidx.work.PeriodicWorkRequest import android.util.Log import android.net.Uri @@ -25,6 +25,15 @@ class MainActivity : ReactActivity() { companion object { const val TAG = "ClipCascade" const val WORK_NAME = "schedule_work" + + fun scheduleJob(context: Context) { + val request = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) + .addTag(WORK_NAME) + .build() + WorkManager.getInstance(context).enqueueUniquePeriodicWork( + WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request + ) + } } /** @@ -48,7 +57,7 @@ class MainActivity : ReactActivity() { val bridgeData = AsyncStorageBridge(applicationContext) val enablePeriodicChecks = bridgeData.getValue("enable_periodic_checks")?.toBoolean() ?: true if(enablePeriodicChecks) { - scheduleJob() + scheduleJob(applicationContext) if(ScheduleService.hasNotificationPermission(applicationContext) == true) { ScheduleService.removeNotificationIfPresent(applicationContext) } @@ -60,26 +69,6 @@ class MainActivity : ReactActivity() { } } - private fun scheduleJob() { - // Check if the work is already scheduled - val workInfos = WorkManager.getInstance(applicationContext).getWorkInfosByTag(WORK_NAME).get() - if (workInfos.isEmpty() || - (workInfos[0].state != androidx.work.WorkInfo.State.RUNNING && - workInfos[0].state != androidx.work.WorkInfo.State.ENQUEUED)) { - // Periodic work request to run every 15 minutes - val periodicWorkRequest: PeriodicWorkRequest = PeriodicWorkRequestBuilder(15, TimeUnit.MINUTES) - .addTag(WORK_NAME) - .build() - - WorkManager.getInstance(applicationContext) - .enqueueUniquePeriodicWork( - WORK_NAME, - ExistingPeriodicWorkPolicy.REPLACE, - periodicWorkRequest - ) - } - } - override fun onPause() { super.onPause() val rm: ReactInstanceManager = reactNativeHost.reactInstanceManager diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainApplication.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainApplication.kt index 13f7caee..3378fabb 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainApplication.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/MainApplication.kt @@ -36,6 +36,7 @@ class MainApplication : Application(), ReactApplication { override fun onCreate() { super.onCreate() + ServerTls.install() SoLoader.init(this, OpenSourceMergedSoMapping) if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { load() diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt index 954f4dac..78c5ef68 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgeModule.kt @@ -47,8 +47,24 @@ class NativeBridgeModule(reactContext: ReactApplicationContext) : ReactContextBa } @ReactMethod - fun stopWorkManager() { - WorkManager.getInstance(reactApplicationContext).cancelAllWorkByTag(MainActivity.WORK_NAME) + fun startWorkManager(promise: Promise) { + try { + MainActivity.scheduleJob(reactApplicationContext) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("SCHEDULE_ERROR", "Failed to start periodic checks", e) + } + } + + @ReactMethod + fun stopWorkManager(promise: Promise) { + try { + WorkManager.getInstance(reactApplicationContext).cancelAllWorkByTag(MainActivity.WORK_NAME) + ScheduleService.removeNotificationIfPresent(reactApplicationContext) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("SCHEDULE_ERROR", "Failed to stop periodic checks", e) + } } @ReactMethod diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgePackage.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgePackage.kt index bbf892c4..f8533c5e 100644 --- a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgePackage.kt +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/NativeBridgePackage.kt @@ -8,7 +8,7 @@ import com.facebook.react.bridge.ReactApplicationContext class NativeBridgePackage : ReactPackage { override fun createNativeModules(reactContext: ReactApplicationContext): List { - return listOf(NativeBridgeModule(reactContext)) + return listOf(NativeBridgeModule(reactContext), ServerTlsModule(reactContext)) } override fun createViewManagers(reactContext: ReactApplicationContext): List> { diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ServerTls.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ServerTls.kt new file mode 100644 index 00000000..8c465d16 --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ServerTls.kt @@ -0,0 +1,125 @@ +package com.clipcascade + +import com.facebook.react.modules.network.CustomClientBuilder +import com.facebook.react.modules.network.NetworkingModule +import com.facebook.react.modules.websocket.WebSocketModule +import java.net.InetAddress +import java.net.Socket +import java.security.KeyStore +import java.security.SecureRandom +import java.security.cert.CertificateException +import java.security.cert.CertificateFactory +import java.security.cert.X509Certificate +import javax.net.ssl.SSLContext +import javax.net.ssl.SSLSocketFactory +import javax.net.ssl.TrustManagerFactory +import javax.net.ssl.X509TrustManager +import okhttp3.ConnectionPool +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import okhttp3.Protocol + +/** One immutable TLS configuration shared by fetch and both WebSocket modes. */ +internal object ServerTls { + private data class Configuration( + val serverUrl: String, + val pem: String, + val factory: SSLSocketFactory, + val trustManager: X509TrustManager, + val pool: ConnectionPool = ConnectionPool(), + ) + + @Volatile private var configuration: Configuration? = null + + fun install() { + val customize = CustomClientBuilder { builder -> applyTo(builder) } + NetworkingModule.setCustomClientBuilder(customize) + WebSocketModule.setCustomClientBuilder(customize) + } + + internal fun applyTo(builder: OkHttpClient.Builder) { + configuration?.let { + builder.sslSocketFactory(it.factory, it.trustManager).connectionPool(it.pool) + // HTTP/2 can reuse one TLS connection for multiple hostnames. Force + // a separate handshake per host to enforce the CA's hostname scope. + .protocols(listOf(Protocol.HTTP_1_1)) + } + } + + fun certificates(bytes: ByteArray): List { + require(bytes.isNotEmpty() && bytes.size <= 1024 * 1024) { + "CA bundle must be between 1 byte and 1 MiB." + } + val certs = CertificateFactory.getInstance("X.509") + .generateCertificates(bytes.inputStream()).map { it as X509Certificate } + require(certs.isNotEmpty()) { "No X.509 certificates found in the CA bundle." } + return certs + } + + @Synchronized fun configure(serverUrl: String, pem: String) { + if (pem.isBlank()) { + configuration?.pool?.evictAll() + configuration = null + return + } + if (configuration?.let { it.serverUrl == serverUrl && it.pem == pem } == true) return + val url = requireNotNull(serverUrl.toHttpUrlOrNull()) { "Invalid server URL." } + // HTTP/WS do not use TLS; keep the bundle available for HTTPS/WSS. + val store = KeyStore.getInstance(KeyStore.getDefaultType()).apply { load(null) } + certificates(pem.toByteArray(Charsets.UTF_8)).forEachIndexed { index, cert -> + store.setCertificateEntry("ca-$index", cert) + } + val system = trustManager(null) + val custom = trustManager(store) + val combined = object : X509TrustManager { + override fun getAcceptedIssuers(): Array = + system.acceptedIssuers + custom.acceptedIssuers + override fun checkClientTrusted(chain: Array, authType: String) = + system.checkClientTrusted(chain, authType) + override fun checkServerTrusted(chain: Array, authType: String) { + try { + system.checkServerTrusted(chain, authType) + } catch (e: CertificateException) { + custom.checkServerTrusted(chain, authType) + } + } + } + val systemFactory = context(system).socketFactory + val customFactory = context(combined).socketFactory + val next = Configuration(serverUrl, pem, + HostSocketFactory(url.host, systemFactory, customFactory), combined) + configuration?.pool?.evictAll() + configuration = next + } + + private fun trustManager(store: KeyStore?): X509TrustManager = + TrustManagerFactory.getInstance(TrustManagerFactory.getDefaultAlgorithm()).apply { + init(store) + }.trustManagers.filterIsInstance().single() + + private fun context(manager: X509TrustManager): SSLContext = + SSLContext.getInstance("TLS").apply { init(null, arrayOf(manager), SecureRandom()) } + + // OkHttp supplies the URL hostname when layering TLS over its connected socket. + // Select the trust context here so redirects and unrelated hosts cannot use the + // imported CA. Keep OkHttp's default hostname verifier unchanged. + private class HostSocketFactory( + private val host: String, + private val system: SSLSocketFactory, + private val custom: SSLSocketFactory, + ) : SSLSocketFactory() { + private fun factory(name: String) = + if (name.removePrefix("[").removeSuffix("]").equals(host, ignoreCase = true)) custom else system + override fun getDefaultCipherSuites(): Array = system.defaultCipherSuites + override fun getSupportedCipherSuites(): Array = system.supportedCipherSuites + override fun createSocket(): Socket = system.createSocket() + override fun createSocket(s: Socket, host: String, port: Int, autoClose: Boolean): Socket = + factory(host).createSocket(s, host, port, autoClose) + override fun createSocket(host: String, port: Int): Socket = factory(host).createSocket(host, port) + override fun createSocket(host: String, port: Int, local: InetAddress, localPort: Int): Socket = + factory(host).createSocket(host, port, local, localPort) + override fun createSocket(address: InetAddress, port: Int): Socket = system.createSocket(address, port) + override fun createSocket(address: InetAddress, port: Int, local: InetAddress, localPort: Int): Socket = + system.createSocket(address, port, local, localPort) + } +} diff --git a/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ServerTlsModule.kt b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ServerTlsModule.kt new file mode 100644 index 00000000..96705d41 --- /dev/null +++ b/ClipCascade_Mobile/src/android/app/src/main/java/com/clipcascade/ServerTlsModule.kt @@ -0,0 +1,49 @@ +package com.clipcascade + +import android.net.Uri +import android.util.Base64 +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod + +class ServerTlsModule(context: ReactApplicationContext) : ReactContextBaseJavaModule(context) { + override fun getName() = "ServerTlsModule" + + @ReactMethod + fun importBundle(uri: String, promise: Promise) { + try { + val bytes = requireNotNull(reactApplicationContext.contentResolver.openInputStream(Uri.parse(uri))) { + "Unable to open CA bundle." + }.use { stream -> + val output = java.io.ByteArrayOutputStream() + val buffer = ByteArray(8192) + while (true) { + val count = stream.read(buffer) + if (count == -1) break + require(output.size() + count <= 1024 * 1024) { "CA bundle exceeds 1 MiB." } + output.write(buffer, 0, count) + } + output.toByteArray() + } + val pem = ServerTls.certificates(bytes).joinToString("\n") { cert -> + "-----BEGIN CERTIFICATE-----\n" + + Base64.encodeToString(cert.encoded, Base64.NO_WRAP).chunked(64).joinToString("\n") + + "\n-----END CERTIFICATE-----\n" + } + promise.resolve(pem) + } catch (e: Exception) { + promise.reject("CA_IMPORT_ERROR", "Cannot import CA bundle: ${e.message}", e) + } + } + + @ReactMethod + fun configure(serverUrl: String, pem: String, promise: Promise) { + try { + ServerTls.configure(serverUrl, pem) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("CA_CONFIG_ERROR", "Cannot configure server TLS: ${e.message}", e) + } + } +} diff --git a/ClipCascade_Server/API.md b/ClipCascade_Server/API.md new file mode 100644 index 00000000..f948eab0 --- /dev/null +++ b/ClipCascade_Server/API.md @@ -0,0 +1,1138 @@ +# ClipCascade server API and alternative-client implementation guide + +This reference describes the HTTP endpoints and WebSocket protocols implemented by +the ClipCascade server in this repository. Paths are relative to your server URL +(for example, `http://localhost:8080`). Use HTTPS/WSS when accessing a remote server. +There is no `/api` prefix. + +This document describes the **checked-in implementation**, including the desktop +and mobile payload conventions required to communicate with existing clients. +The server version constant in this tree is `3.1.0`; deployed builds may differ. +There is no separately versioned API, capability-negotiation endpoint, or generated +OpenAPI contract. Probe the running server instead of assuming its mode or limits. + +## Contents + +- [Client implementation roadmap](#client-implementation-roadmap) +- [Authentication and CSRF](#authentication-and-csrf) +- [Response conventions](#response-conventions) +- [Public HTTP endpoints](#public-http-endpoints) +- [Authenticated HTTP endpoints](#authenticated-http-endpoints) +- [Admin HTTP endpoints](#admin-http-endpoints) +- [Clipboard WebSocket protocols](#clipboard-websocket-protocols) +- [Clipboard payload encoding and encryption](#clipboard-payload-encoding-and-encryption) +- [Size limits and deployment configuration](#size-limits-and-deployment-configuration) +- [Recovery and troubleshooting](#recovery-and-troubleshooting) +- [Interoperability acceptance checklist](#interoperability-acceptance-checklist) +- [Implementation sources](#implementation-sources) + +## Client implementation roadmap + +1. Collect the server base URL, exact username, and original password. To work + with official clients, also collect matching encryption settings: enabled + (default `true`), PBKDF2 iterations (default `664937`), and extra salt (default + empty string). The server does not supply these encryption settings. +2. Obtain a pre-login CSRF token and cookie, submit the password digest, retain + the post-login cookies, and confirm identity with `/whoami`. +3. Fetch `/csrf-token`, `/server-mode`, `/server-version`, and `/max-size`. + In P2P mode, also fetch `/stun-url`. +4. Establish the selected authenticated WebSocket transport. P2S uses STOMP; + P2P uses JSON signaling followed by WebRTC data channels. +5. Serialize text/images/files, optionally encrypt the resulting string, and + wrap it in the clipboard envelope. Fragment only for P2P data channels. +6. Receive, reassemble where needed, decrypt, decode, and apply the clipboard + update. Suppress local clipboard events caused by received updates and suppress + your own P2S echo. +7. Handle disconnects, expired sessions, peer changes, and explicit logout. + Reconnect transports and subscriptions; there is no offline replay. + +| Property | P2S | P2P | +| --- | --- | --- | +| Default server mode | Yes | Opt-in | +| Socket endpoint | `/clipsocket` | `/p2psignaling` | +| Socket application protocol | STOMP | JSON signaling | +| Clipboard transport | Server relays STOMP messages | WebRTC `cliptext` data channels | +| Recipient selection | All subscribed sessions of the authenticated username | Client sends to each connected peer of that username | +| Sender receives own update | Yes, when subscribed | Client does not create a connection to itself | +| Server sees clipboard envelope | Yes; payload may be encrypted | No; sees signaling | +| Clipboard persistence / history / replay | None | None | + +A device is a connection, not a registered resource. There are no device tokens, +HTTP clipboard upload/download routes, room-management routes, delivery-status +routes, or password-reset/email-verification flows in this API. The `email` and +`otp` admin record fields do not imply such flows exist. + +## Authentication and CSRF + +The server uses Spring Security form login and session cookies. It does not +provide API keys, bearer tokens, or HTTP Basic authentication. Preserve cookies +across requests, including the session cookie returned after login; the cookie +name is normally `JSESSIONID`. The optional JDBC session store explicitly uses +`JSESSIONID` too. Treat the cookie value as opaque and honor its path/domain and +Secure attributes; never decode or invent it. + +1. `GET /login` and retain the cookies. Read the hidden `_csrf` input from the HTML. +2. `POST /login` with form fields `username`, `password`, and `_csrf`, using the + same cookies. The password must be the **lowercase hexadecimal SHA3-512 digest + of the UTF-8 password**, matching the official clients. Do not use Keccak-512. +3. Successful login redirects to `/`; failed login redirects to `/login?error`. + A redirect or an HTML response alone does not prove authentication succeeded. +4. Verify the session with `GET /whoami`. Fetch `GET /csrf-token` after login to + obtain the current token for subsequent mutations. + +`POST`, `PUT`, and `DELETE` requests require a CSRF token, including login, +signup, and logout. For authenticated JSON requests, send the token in the header +named by `headerName` in `/csrf-token` (normally `X-CSRF-TOKEN`). Form requests can +send it using `parameterName` (normally `_csrf`). Fetch a new token after signing +in again. `/csrf-token` itself requires authentication; use the login/signup HTML +to obtain an initial token. + +Example using Python with the `requests` package installed: + +```python +import getpass +import hashlib +from html.parser import HTMLParser + +import requests + + +class CsrfParser(HTMLParser): + token = None + + def handle_starttag(self, tag, attrs): + attrs = dict(attrs) + if tag == "input" and attrs.get("name") == "_csrf": + self.token = attrs.get("value") + + +base = "http://localhost:8080" +session = requests.Session() +login_page = session.get(base + "/login", timeout=15) +login_page.raise_for_status() +parser = CsrfParser() +parser.feed(login_page.text) +if not parser.token: + raise RuntimeError("Login page did not provide a CSRF token") + +username = input("Username: ") +raw_password = getpass.getpass("Password: ") +password = hashlib.sha3_512(raw_password.encode("utf-8")).hexdigest() +response = session.post( + base + "/login", + data={"username": username, "password": password, "_csrf": parser.token}, + allow_redirects=False, + timeout=15, +) +from urllib.parse import urlsplit + +if response.status_code != 302 or urlsplit(response.headers.get("Location", "")).path != "/": + raise RuntimeError("Login failed: " + str(response.status_code)) + +identity = session.get(base + "/whoami", allow_redirects=False, timeout=15) +identity.raise_for_status() +print(identity.json()) # {"username": "...", "role": "USER"} or role "ADMIN" + +response = session.get(base + "/csrf-token", timeout=15) +response.raise_for_status() +csrf = response.json() +session.headers[csrf["headerName"]] = csrf["token"] + +# Reuse session for authenticated requests, including JSON mutations: +# session.put(base + "/update-password", json={"newPassword": new_digest}, timeout=15) +``` + +Password fields in signup, registration, and password updates use the same +SHA3-512 digest convention. The server applies BCrypt to the submitted value for +storage. Usernames must be nonblank and cannot start or end with spaces. Roles +are uppercase `USER` and `ADMIN`; admin registration permits only `USER`. + +### Credential and session lifecycle + +The digest is 128 lowercase hexadecimal characters. SHA3-512 and the clipboard +PBKDF2 key are separate derivations: **keep the original password for encryption**. +The server BCrypt-hashes the submitted credential; its validator only checks +that a password is nonempty, not that it has the correct digest shape. Submitting +plaintext during registration can therefore create an account incompatible with +the official login convention. The login digest is itself a reusable credential; +protect it and the session cookie as you would a password. + +Usernames are used as supplied for login and key derivation; do not silently +lowercase or normalize them. Registration checks existing names case-insensitively, +while authentication looks up the supplied ID. Use the exact stored spelling. +There is no application-level maximum-length contract beyond database/framework +constraints. Username and password changes require deriving a new clipboard key +on every client because both inputs participate in key derivation. + +- Multiple concurrent login sessions are allowed (no configured maximum). +- `POST /logout` ends the current HTTP session. Explicitly close your socket and + P2P peer connections as part of logout; this route does not call the application's + all-session WebSocket revocation service. +- `DELETE /logout-session` revokes every session for the current username, not + merely the calling device. Admin per-user/global logout does the analogous + operation. Account password/status/name changes and deletion also revoke the + affected user's sessions and close tracked local sockets with code `1008`, + reason `Session revoked`. +- Revoking signaling does not itself terminate an already-established direct + WebRTC channel. A client must close its data channels and peer connections when + its session is revoked or it logs out. +- Default HTTP inactivity timeout is `525960m`, configurable by the operator. + Do not assume a live socket refreshes HTTP session activity. Check + `/validate-session` when resuming or troubleshooting a connection. +- With session persistence disabled (default), a server restart loses in-memory + sessions. JDBC persistence can retain unexpired sessions across restart; sockets + always need reconnecting. Disabling persistence clears its stored sessions. + +### Signup sequence + +Fetch `/signup` for the CSRF token, then `/captcha` using the same cookie jar. +Display the PNG to the user and submit the answer with the username, password +**digest**, and CSRF token as URL-encoded form data. The captcha is 200 × 50 pixels +with 5–6 alphanumeric characters, compared case-insensitively. Fetching another +captcha replaces the previous answer. Validation consumes the answer even on a +failed attempt, so fetch a fresh captcha before retrying signup. Registration +success is plain text and does not create an authenticated session: log in next. +Duplicate usernames, invalid fields, and the operator's account-count limit can +all cause registration failure. There is no public signup-enabled JSON endpoint. + +### Browser clients and origins + +A same-origin browser client can use its cookie jar automatically for WebSockets +and same-origin `fetch`. JavaScript cannot set a `Cookie` header on a browser +WebSocket. Native clients must copy the applicable authenticated cookies into the +HTTP upgrade request. Do not put credentials in a WebSocket URL or STOMP body. + +`CC_ALLOWED_ORIGINS` controls WebSocket origin checks; it does not enable +cross-origin HTTP CORS. This source has no general HTTP CORS configuration. A +separately hosted browser client therefore needs deployment support for its HTTP +requests and cookies, or a same-origin reverse proxy. Persistent-session cookies +use HttpOnly and SameSite=Lax; Secure follows whether the request is secure. +Configure TLS/proxy forwarding consistently and retain certificate verification. +Routes below are application-root paths; a proxy path prefix requires appropriate +rewrites, including redirects (login success explicitly redirects to `/`). + +## Response conventions + +Successful data requests return JSON objects/arrays or plain text, as listed +below. Successful mutations generally return `200` with a plain-text message, +such as `Password updated successfully`; there is no common JSON response wrapper. + +Validation failures and exceptions handled by `ResponseEntityUtil` return `400` +with plain-text error messages. Most admin role checks also return `400 Forbidden`. +The two admin session logout endpoints explicitly return `403 Forbidden` for a +non-admin. Missing/invalid CSRF tokens produce `403` through Spring Security. +Unauthenticated requests generally redirect to `/login`; expired sessions can +redirect to `/login?expired`. Clients should inspect redirects and content types +before parsing JSON. Malformed request bodies or unhandled exceptions may produce +Spring's error responses rather than these plain-text messages. + +## Public HTTP endpoints + +These routes do not require authentication. Public mutations still require CSRF. + +| Method | Path | Input / response | +| --- | --- | --- | +| GET | `/health` | Plain text `OK`. | +| GET | `/ping` | Plain text `pong`. | +| GET | `/login` | Login HTML with a CSRF form token. | +| POST | `/login` | Form fields `username`, `password`, `_csrf`; redirects as described above. | +| GET | `/signup` | Signup HTML if enabled; otherwise redirects to `/`. | +| POST | `/signup` | Form fields `username`, `password`, `captchaInput`, `_csrf`. Returns `User registered successfully` or `400` (for example, `Captcha validation failed` or `Signup is disabled`). Does not log the user in. | +| GET | `/captcha` | PNG captcha when signup is enabled; keep its session cookies for signup. Returns an empty body when signup is disabled. | +| GET | `/logout` | Logout confirmation HTML; does not itself perform logout. | +| POST | `/logout` | CSRF-protected session logout; redirects to `/login?logout`. | +| GET | `/help` | Redirects to the configured project help URL. | +| GET | `/donate` | Redirects to the donation URL when enabled, otherwise `/`. | + +Static assets under `/assets/**` are public. `GET /` serves the authenticated +home UI (static `index.html`); it requires a session. + +## Authenticated HTTP endpoints + +All routes here require a logged-in user, including the configuration endpoints. + +| Method | Path | Input / response | +| --- | --- | --- | +| GET | `/whoami` | `{"username":"alice","role":"USER"}`. | +| GET | `/validate-session` | Plain text `OK`. | +| GET | `/csrf-token` | JSON containing `token`, `headerName`, and `parameterName`. | +| GET | `/server-mode` | `{"mode":"P2P"}` or `{"mode":"P2S"}`. | +| GET | `/stun-url` | `{"url":"..."}`; empty string in P2S mode. | +| GET | `/max-size` | `{"maxsize":1048576}`; configured message size in bytes (example value). | +| GET | `/max-message-size` | `{"maxmessagesize":1.0}`; configured message size in MiB (example value). | +| GET | `/donation-status` | `{"enabled":true}` or `{"enabled":false}`. | +| GET | `/server-version` | `{"version":"..."}` for the running server. | +| GET | `/latest-server-version` | JSON fetched from the project's version URL; fields follow that upstream document. Can return `400` if fetching/parsing fails. | +| PUT | `/update-password` | JSON `{"newPassword":""}`. Updates the current user's password and expires their sessions. | +| DELETE | `/logout-session` | No body. Logs the current user out of all active sessions; returns a plain-text status. | + +## Admin HTTP endpoints + +All `/admin/*` routes below require an authenticated `ADMIN`. Mutations use +`Content-Type: application/json` when a JSON body is listed, plus a CSRF header. + +| Method | Path | Input / response | +| --- | --- | --- | +| GET | `/admin/advance` | HTML showing server configuration; a non-admin is redirected to `/`. | +| GET | `/admin/server-version` | Same response as `/server-version`. | +| GET | `/admin/latest-server-version` | Same response as `/latest-server-version`. | +| GET | `/admin/server-time` | Plain text `Epoch time: `. | +| GET | `/admin/websocket-stats` | Plain-text statistics for the active WebSocket mode. | +| GET | `/admin/online-users` | JSON map of usernames to live connection counts, e.g. `{"alice":2}`. | +| GET | `/admin/all-users` | JSON array of user records, including the admin. | +| GET | `/admin/users` | JSON array of `USER` records. | +| GET | `/admin/user-details` | JSON array of `UserInfo` records. | +| GET | `/admin/bfa-snapshot` | JSON brute-force tracker map keyed by username; each value contains `ipAccessDetails`, `uniqueIpSet`, and `lockTimeout`. | +| GET | `/admin/bfa-snapshot-file` | Serialized tracker JSON as a byte response. A non-admin receives an empty body. | +| GET | `/admin/users/page` | Paginated user records; parameters below. | +| GET | `/admin/user-details/page` | Paginated user details, with an additional `enabled` field. | +| GET | `/admin/online-users/page` | Paginated records with `username` and `connections`. | +| GET | `/admin/bfa-snapshot/page` | Paginated tracker rows, one per username/source IP. | +| POST | `/admin/register-user` | JSON `{"username":"alice","password":"","role":"USER","enabled":true}`. `enabled` defaults to `true` if omitted. | +| PUT | `/admin/toggle-user-status` | JSON `{"username":"alice","enabled":"false"}` (string value). Expires the target user's sessions. | +| PUT | `/admin/update-user-password` | JSON `{"username":"alice","newPassword":""}`. Expires the target user's sessions. | +| PUT | `/admin/update-username` | JSON `{"oldUsername":"alice","newUsername":"alice2"}`. Expires sessions for the old username. | +| PUT | `/admin/unlock-user` | JSON `{"username":"alice"}`; removes the user's brute-force tracker entry. | +| DELETE | `/admin/delete-user` | JSON `{"username":"alice"}`. Expires sessions; the admin cannot be deleted. | +| DELETE | `/admin/logout-user-session` | JSON `{"username":"alice"}`. Logs out all sessions for that user. | +| DELETE | `/admin/logout-all-sessions` | No body. Logs out every user's sessions, including the admin's. | + +User records contain `username`, `password` (returned as `null`), `role`, and +`enabled`. `UserInfo` fields are `username`, `markedForDeletion`, `firstSignup`, +`lastLogin`, `firstSignupIp`, `lastLoginIp`, `failedLoginAttempts`, `lockoutTime`, +`passwordChangedAt`, `email`, `otp`, `phone`, `firstName`, `lastName`, and `notes`. +Fields may be null. Paginated brute-force rows contain `username`, `sourceIp`, +`attempts`, `lockCount`, `lockTimeout`, `uniqueIpCount`, and `userLockTimeout`. + +### Admin record schemas and operation results + +`/admin/users` and `/admin/users/page` include only `USER` accounts; +`/admin/all-users` also includes the admin. User-info listings can contain records +marked for deletion whose user account no longer exists. There is one admin +account enforced at server startup; registration cannot create a second admin. + +Example user record (the `password` field is deliberately cleared in listings): + +```json +{"username":"alice","password":null,"role":"USER","enabled":true} +``` + +| User-info field | JSON type / interpretation | +| --- | --- | +| `username` | String account identifier | +| `markedForDeletion` | Boolean; retained record scheduled for startup purge | +| `firstSignup`, `lastLogin`, `passwordChangedAt` | Integer Unix epoch **seconds**, or null | +| `firstSignupIp`, `lastLoginIp` | String, or null | +| `failedLoginAttempts` | Integer login-history value; not the live brute-force tracker | +| `lockoutTime` | Human-readable duration string, or null; not an expiry timestamp | +| `email`, `otp`, `phone`, `firstName`, `lastName`, `notes` | String, or null; no public edit endpoints for these fields | +| `enabled` | Only in paginated user-details: boolean or null if no matching account | + +Paginated user-details are **flat** objects, not `{info: {...}, enabled: ...}`. +The live brute-force snapshot is a map; its nested shape is: + +```json +{ + "alice":{ + "ipAccessDetails":{ + "192.0.2.10":{"attempts":2,"lockCount":0,"lockTimeout":0} + }, + "uniqueIpSet":["192.0.2.10"], + "lockTimeout":0 + } +} +``` + +Here both `lockTimeout` fields are integer Unix epoch **seconds**; zero is the +reset epoch, not an indefinite lock. The outer timeout is user-wide; the nested +one is per source IP. `uniqueIpSet` serializes as an unordered array. +The paginated form flattens each username/IP into a row: + +```json +{"username":"alice","sourceIp":"192.0.2.10","attempts":2,"lockCount":0,"lockTimeout":0,"uniqueIpCount":1,"userLockTimeout":0} +``` + +`/admin/bfa-snapshot-file` contains serialized JSON bytes of the tracker; it does +not promise a download filename or a JSON content type. `/admin/websocket-stats` +is human-readable, mode-dependent text, not a stable machine-readable schema. +Online-user counts describe live transport connections, not registered devices +or open P2P data channels. In P2P, a user with two signaling connections can still +have zero successfully negotiated data channels. + +Successful mutations return `200` with the following plain-text bodies: + +| Route | Success body | +| --- | --- | +| `/signup`, `/admin/register-user` | `User registered successfully` | +| `/update-password`, `/admin/update-user-password` | `Password updated successfully` | +| `/admin/update-username` | `Username updated successfully` | +| `/admin/toggle-user-status` | `User enabled successfully` or `User disabled successfully` | +| `/admin/delete-user` | `User deleted successfully` | +| `/admin/unlock-user` | `User unlocked successfully` | +| `/admin/logout-all-sessions` | `All users have been logged out of all active sessions.` | +| `/logout-session`, `/admin/logout-user-session` | `User 'alice' has been logged out of all active sessions.` or `No active sessions found for username: alice` | + +These endpoints do not return the changed user record. Fetch the appropriate +listing after a change if the calling session remains valid. Representative +application failures are `400` with `Invalid user or password`, `Invalid user or +username`, `Invalid user or operation failed`, `User already exists or invalid +user`, `Role must be user`, `admin user cannot be deleted`, `Invalid username`, +or `User not found`. Exact error selection depends on the operation and validation +path; do not use string matching as a substitute for status handling. + +The status-update endpoint parses its string `enabled` with +`Boolean.parseBoolean`: only case-insensitive `"true"` enables; other/missing values +become false. Always send an explicit `"true"` or `"false"`. Registration's `enabled` +is a JSON boolean instead. Password updates do not require an `oldPassword` field. +Rename can also rename the admin and invalidates its sessions if it is the target. +Deleting or renaming marks old user-info records for deletion, which can prevent +immediate reuse of the old name until startup cleanup. There is no user-facing +self-delete route. + +Example authenticated mutation, using the `session` initialized above: + +```python +# Administrative example; execute only when you intend to create this account. +# new_digest = hashlib.sha3_512(new_password.encode("utf-8")).hexdigest() +# result = session.post(base + "/admin/register-user", json={ +# "username": "alice", "password": new_digest, +# "role": "USER", "enabled": True, +# }, allow_redirects=False, timeout=15) +# print(result.status_code, result.text) +``` + +### Pagination, sorting, and search + +The four `/page` endpoints accept `page` (zero-based, default `0`), `size` (default +`10`), `sort`, `direction`, and `search`. Search trims whitespace and matches +case-insensitive substrings. `direction=desc` is case-insensitive; any other +nonempty direction is ascending. Unknown sort keys leave rows unsorted. + +| Endpoint | Default sort / direction | Supported sort keys | Search fields | +| --- | --- | --- | --- | +| `/admin/users/page` | `username` / `asc` | `username`, `enabled` | Username | +| `/admin/user-details/page` | `lastLogin` / `desc` | All `UserInfo` fields above, plus `enabled` | Username, email, first/last name, phone, signup/login IP, notes | +| `/admin/online-users/page` | `connections` / `desc` | `username`, `connections` | Username | +| `/admin/bfa-snapshot/page` | `username` / `asc` | All brute-force row fields above | Username, source IP | + +`size <= 0` returns all matching rows on page `0`. Page numbers outside the valid +range are clamped. Even an empty result reports at least one page. Response example: + +```json +{ + "content": [{"username":"alice","connections":2}], + "totalElements": 1, + "totalPages": 1, + "page": 0, + "size": 10, + "sort": "connections", + "direction": "desc", + "search": null +} +``` + +## Clipboard WebSocket protocols + +First log in over HTTP, then send the authenticated session cookie with the +WebSocket upgrade request. Convert `http` → `ws` or `https` → `wss` and append the +endpoint to the server base URL. Preserve path casing. Origin checks use +`CC_ALLOWED_ORIGINS`. Only the configured mode's socket is registered. These are +native WebSockets; there is no SockJS endpoint or HTTP long-poll fallback. +The HTTP session supplies the principal: STOMP `login`/`passcode` headers are not +a substitute for the cookie. The current configuration does not require a CSRF +header inside a STOMP CONNECT frame. + +### P2S: STOMP over `/clipsocket` + +Use a STOMP-capable WebSocket library (the bundled desktop client advertises +STOMP versions `1.0,1.1`). A connection proceeds as follows: + +1. Upgrade to `/clipsocket` with the session cookie. +2. Send `CONNECT`; await `CONNECTED` before subscribing or sending. +3. `SUBSCRIBE` with a unique subscription ID to `/user/queue/cliptext`, normally + `ack:auto`. +4. `SEND` to `/app/cliptext` with `content-type:application/json` and a clipboard + envelope as the body. +5. Process `MESSAGE` frames for your subscription and tolerate heartbeat newlines. +6. On disconnect, repeat CONNECT and SUBSCRIBE; subscription IDs are local to a + connection. Handle `ERROR` frames without trying to parse them as clipboard JSON. + +Wire example (`\0` below means one NUL byte, not the two characters backslash/zero; +headers and the blank line use LF). Replace `host` with your server hostname: + +```text +CONNECT +accept-version:1.2 +host:localhost +heart-beat:0,20000 + +\0 +``` + +```text +SUBSCRIBE +id:clipboard-0 +destination:/user/queue/cliptext +ack:auto + +\0 +``` + +```text +SEND +destination:/app/cliptext +content-type:application/json + +{"payload":"Hello from a client","type":"text"}\0 +``` + +The last example is plaintext and interoperates with clients configured with +**encryption disabled**. For the default encrypted configuration use the encoding +section below. In real frames, there is no newline between the body and NUL. +When `content-length` is present it counts UTF-8 **body bytes**, excluding the +terminating NUL, not characters or the whole frame. JSON serialization must escape +embedded newlines/control characters. A stream parser must support multiple +frames per received chunk and frames split across chunks; use the declared length +when present and skip heartbeat newlines outside frames. + +The server emits an envelope to connected, subscribed sessions of the **same +username, including the sender**. It preserves `payload`, `type`, and `metadata`; +missing/null `type` becomes `text`. Missing metadata can serialize as `null`. +The routing identity comes from authentication, not a username inside the payload. +There is no message ID, sequence number, durable acknowledgement, or delivery +confirmation in the application envelope. `ack:auto` needs no client ACK. Do not +build exactly-once or offline-delivery assumptions on STOMP transport receipts. + +The simple broker advertises `heart-beat:20000,0`. With the client's +`heart-beat:0,20000`, expect server heartbeats during idle periods at roughly +20-second intervals; server-required client heartbeats are disabled. If another +pair is negotiated, respect it. External broker deployments can differ. +Use scheduling/network tolerance when detecting missed heartbeats. + +### P2P: signaling over `/p2psignaling` + +This socket exchanges JSON text messages without STOMP or NUL terminators. +The server creates one user room per authenticated username. Every connection +receives a fresh UUID peer ID, even when reconnecting from the same device. + +```json +{"type":"ASSIGNED_ID","peerId":""} +``` + +```json +{"type":"PEER_LIST","peers":["",""]} +``` + +`ASSIGNED_ID` is sent to the new connection before the handler broadcasts its +updated peer list. `PEER_LIST` includes yourself; array order is unspecified. +Lists are full snapshots, broadcast on connection and disconnection. Exclude +self, create connections for new peers, and dispose connections for removed peers. +There is no explicit JOIN, LEAVE, or fetch-peers command. + +| Message | Sender → recipient | Required application fields | +| --- | --- | --- | +| `ASSIGNED_ID` | Server → new connection | `peerId`: string | +| `PEER_LIST` | Server → every connection in the user room | `peers`: array of strings | +| `OFFER` | Client → server → target peer | `fromPeerId`, `toPeerId`, `offer: {type:"offer", sdp:string}` | +| `ANSWER` | Client → server → target peer | `fromPeerId`, `toPeerId`, `answer: {type:"answer", sdp:string}` | +| `ICE_CANDIDATE` | Client → server → target peer | `fromPeerId`, `toPeerId`, `candidate: {candidate:string, sdpMid:string, sdpMLineIndex:integer}` | + +Example offer and corresponding answer (SDP is abbreviated, not executable): + +```json +{"type":"OFFER","fromPeerId":"peer-a","toPeerId":"peer-b","offer":{"type":"offer","sdp":""}} +``` + +```json +{"type":"ANSWER","fromPeerId":"peer-b","toPeerId":"peer-a","answer":{"type":"answer","sdp":""}} +``` + +```json +{"type":"ICE_CANDIDATE","fromPeerId":"peer-a","toPeerId":"peer-b","candidate":{"candidate":"candidate:1 1 udp 2130706431 192.0.2.10 50000 typ host","sdpMid":"0","sdpMLineIndex":0}} +``` + +The server routes only the three uppercase signaling types above, finds +`toPeerId` inside the authenticated user's room, and forwards the original JSON +unchanged. Missing/unknown targets and unknown types are silently ignored; there +is no error response or acknowledgement. It does **not** validate SDP or rewrite +or authenticate the supplied `fromPeerId`. Validate incoming sender/target IDs +against your current peer list and connection state. Malformed JSON can fail the +handler rather than producing a structured protocol error. Use text frames; +the handler also attempts to interpret binary frames as UTF-8 JSON. + +#### WebRTC negotiation compatible with official clients + +For each remote peer, maintain one `RTCPeerConnection`. Use the URL returned by +`/stun-url` as an ICE server URL when nonempty. The discovery API provides one +URL, not TURN credentials or a list of TURN servers. NAT/firewall conditions can +prevent direct connectivity; the server has no automatic TURN relay or P2S +fallback within a P2P deployment. + +The official clients break offer collisions by comparing peer IDs +lexicographically: **the smaller peer ID initiates the offer**. + +1. The initiator creates an ordinary reliable, ordered data channel named + **`cliptext`**, then creates an offer and sets its local description. +2. Send the local SDP as `OFFER` to the larger peer ID. The answering client + applies it as its remote description, creates and sets its local answer, and + sends `ANSWER` back. The initiator applies that remote answer. +3. Accept the remote channel through the data-channel event on the answerer; + do not create an extra channel there. Send nonempty ICE candidates through + `ICE_CANDIDATE`. Do not send a null end-of-candidates marker to the desktop + handler, which expects a candidate string. SDP can also contain gathered + candidates (the desktop implementation sends its local description after + setting it). +4. Queue early incoming candidates until the appropriate peer connection and + remote description are ready. Apply only candidates/answers belonging to the + current negotiation; stale messages can arrive after peer churn. +5. Wait for the data channel's `open` state before sending clipboard envelopes. + Broadcast to each open remote channel. The server never relays these envelopes. + +For recovery, official clients can replace a failed connection with a new offer; +this is a fresh negotiation, not necessarily an ICE-restart update on the old +connection. Close and replace stale peer state, preserve the smaller-ID initiator +rule, and avoid competing offers. On signaling reconnect, discard the old peer +ID, peer connections, and fragment buffers, then rebuild from the new assignment. + +#### Data-channel heartbeat and fragmentation + +Messages on `cliptext` are JSON strings. Ignore the following control message +before looking for `payload`, `type`, or `metadata`: + +```json +{"_cc_keepalive":true} +``` + +Official clients send this roughly every 20 seconds on idle/live channels. It is +not a clipboard update, does not require a reply, and does not replace WebSocket +ping/pong on the signaling connection. + +The clipboard envelope is the same as P2S, with fragmentation metadata: + +```json +{ + "payload":"Hello", + "type":"text", + "metadata":{ + "id":"11111111-2222-4333-8444-555555555555", + "isFragmented":false, + "index":0, + "totalFragments":1, + "combinedRawPayloadSizeInBytes":5 + } +} +``` + +| Metadata field | Type | Meaning | +| --- | --- | --- | +| `id` | string | Fresh transfer UUID; identical across its fragments and recipients | +| `isFragmented` | boolean | Whether more than one fragment was generated | +| `index` | integer | Zero-based fragment position | +| `totalFragments` | integer | Total fragment count (one for an unfragmented message) | +| `combinedRawPayloadSizeInBytes` | integer | UTF-8 size of the complete serialized plaintext payload **before encryption**, not the enclosing envelope or original file byte count | + +Sender algorithm: + +1. Serialize the content into the payload string described below. Record its + UTF-8 length for `combinedRawPayloadSizeInBytes`. +2. If encryption is enabled, encrypt the **whole string once** and serialize the + nonce/ciphertext/tag object into the new payload string. +3. Split that string into chunks of at most **15360 UTF-8 bytes** (15 KiB), without + splitting a Unicode character. This limit excludes the outer JSON envelope; + escaping can make the actual channel message larger. +4. Send one JSON envelope per chunk, in increasing index order, with the same + ID/type/count/size and an incrementing `index`. Unfragmented messages still use + the complete metadata object. Do not send an empty `{}` metadata object to an + official P2P client: it expects the fields above when metadata is present. +5. Use data-channel buffered-amount/backpressure support to avoid unbounded queued + sends. Finish one transfer before starting another for best interoperability. + +Receiver algorithm: + +1. Drop keepalive messages. Check types, count/index bounds, transfer identity, + and local byte/count limits before allocating buffers. +2. Collect fragments by transfer ID (and sending peer in your own implementation). + Require consistent metadata and join payload strings in index order only when + all parts exist. Then parse/decrypt the combined encrypted payload, if enabled. +3. Decode the plaintext according to `type`. Never decrypt individual fragments + or JSON-parse a partial encrypted payload string. +4. Discard abandoned buffers on disconnect or timeout; desktop expires partial + buffers after 60 seconds without fragment activity. Bound actual accumulated + bytes as well as the sender-declared size. + +Official clients also accept an unfragmented envelope with missing/null metadata. +For compatibility, send ordered fragments: their receive logic expects completion +when the last-index fragment arrives and can discard incomplete/interleaved +transfers. A newly copied/received clipboard value can cancel in-flight work; +there is no fragment retry, acknowledgement, resume, or transfer-cancel message. +With encryption disabled, the official fragmenter emits no fragments for an empty +string, so do not depend on an empty P2P payload to clear other clients' clipboards. + +#### Signaling liveness and limits + +The server periodically sends RFC 6455 **ping control frames** every 40 seconds. +The client must respond with **pong control frames** (browser stacks handle this +internally). The handler checks for a recorded pong older than 120 seconds and +closes the socket with code `1001` at a heartbeat check. Ordinary JSON traffic, +a newline, or a data-channel keepalive does not update this pong timestamp. + +P2P connections exceeding the configured global/per-user limits close with +`1008` (policy violation); a missing authenticated principal also closes this way. +The optional per-connection signaling limiter is a token bucket: capacity and +refill per second both equal `CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC`. Values `<= 0` +disable it. Excess nonempty messages are silently dropped, with no retry hint. +Avoid rapid reconnect/offer loops and bursts of redundant candidates. + +## Clipboard payload encoding and encryption + +These are **client interoperability conventions**, not server-side content +validation. The P2S server treats the payload as a string and does not encrypt, +decrypt, inspect image bytes, or save uploaded files. P2P signaling does not see +clipboard content at all. + +### Common envelope + +| Field | Type | Contract | +| --- | --- | --- | +| `payload` | string | Serialized plaintext content or a serialized encrypted object; never a nested object | +| `type` | string | Official values are lowercase `text`, `image`, `files`; send explicitly | +| `metadata` | object or null, optional | P2P fragmentation fields above; passed through by P2S | + +Do not infer encryption from the type: the type stays `text`, `image`, or `files` +even when encrypted. There is no encryption flag, algorithm version, key ID, +compression field, timestamp, MIME field, or sender-device ID in the standard +envelope. Unknown types/custom metadata have no official client behavior. + +### Plaintext content formats + +| Type | Plaintext payload string | Example / notes | +| --- | --- | --- | +| `text` | Original text | No Base64; preserve Unicode and line breaks | +| `image` | Standard Base64 of encoded image file bytes | No `data:image/...;base64,` prefix. Desktop preserves an existing image format or uses PNG; use PNG for broad compatibility. Not raw pixel bytes. | +| `files` | JSON string mapping base filenames to standard Base64 file contents | No ZIP, multipart upload, path hierarchy, or download URL | + +Plaintext file envelope containing `hello.txt` with five bytes `Hello`: + +```json +{"payload":"{\"hello.txt\":\"SGVsbG8=\"}","type":"files"} +``` + +Parse the outer JSON first, then parse `payload` as JSON for `files`; Base64-decode +each value separately. File keys are names such as `hello.txt`, not paths. +Duplicate basenames overwrite each other in the desktop sender's dictionary. +For an alternative receiver, constrain saves to its selected download directory, +reject path separators/traversal names, and resolve existing-file conflicts before +writing. Clipboard peers supply these names; the server does not sanitize them. +Feature support and clipboard permissions vary by platform; receiving data does +not guarantee an OS clipboard update or an automatic file save. + +### Encryption derivation and wire representation + +All clients sharing data must agree on the exact username, original password, +extra salt, iteration count, and encryption-enabled setting. Defaults in desktop +and mobile are encryption enabled, `664937` iterations, and empty extra salt. +These values are local configuration and are not stored/distributed by this API. + +```text +loginCredential = lowercaseHex(SHA3-512(UTF8(originalPassword))) +key = PBKDF2-HMAC-SHA256( + password = UTF8(originalPassword), + salt = UTF8(username + originalPassword + extraSalt), + iterations = configuredIterations, + outputLength = 32 bytes +) +``` + +Concatenation has **no delimiter**. Do not substitute the login digest as the +PBKDF2 password, trim the extra salt, or add Unicode normalization. + +Encrypt UTF-8 bytes of the serialized plaintext payload with AES-256-GCM using +that 32-byte key and a fresh random nonce for **every** encryption. No additional +authenticated data (AAD) is supplied. The authentication tag is 16 bytes. +The desktop's PyCryptodome call generates a nonce (normally 16 bytes); nonce length +is carried by the decoded field, so do not hard-code a 12-byte receive-only +assumption. Preserve the supplied nonce length when decrypting. + +Encode nonce, ciphertext, and tag separately with standard padded Base64, then +JSON-serialize this object **into a string** for the outer `payload`: + +```json +{"nonce":"","ciphertext":"","tag":""} +``` + +If your crypto API returns ciphertext with its tag appended, split the final 16 +bytes before encoding. Reverse that step for an API expecting a combined input. +Verify authentication before decoding or applying plaintext. Do not silently fall +back to plaintext if decryption fails. The outer `type` and `metadata` are not +included as GCM AAD by the official clients. + +Python implementation compatible with the desktop format (`pycryptodome`): + +```python +import base64 +import hashlib +import json +from Crypto.Cipher import AES + + +def derive_key(username, original_password, extra_salt="", rounds=664937): + return hashlib.pbkdf2_hmac( + "sha256", + original_password.encode("utf-8"), + (username + original_password + extra_salt).encode("utf-8"), + rounds, + dklen=32, + ) + + +def encrypt_payload(plaintext, key): + cipher = AES.new(key, AES.MODE_GCM) # Fresh random nonce each call. + ciphertext, tag = cipher.encrypt_and_digest(plaintext.encode("utf-8")) + return json.dumps({ + "nonce": base64.b64encode(cipher.nonce).decode("ascii"), + "ciphertext": base64.b64encode(ciphertext).decode("ascii"), + "tag": base64.b64encode(tag).decode("ascii"), + }) + + +def decrypt_payload(payload, key): + obj = json.loads(payload) + parts = {name: base64.b64decode(obj[name], validate=True) + for name in ("nonce", "ciphertext", "tag")} + cipher = AES.new(key, AES.MODE_GCM, nonce=parts["nonce"]) + return cipher.decrypt_and_verify(parts["ciphertext"], parts["tag"]).decode("utf-8") + + +# Reuse username and raw_password from the login example. +# key = derive_key(username, raw_password) +# body = json.dumps({"payload": encrypt_payload("Hello", key), "type": "text"}) +``` + +The complete transformation order is: + +```text +Send: content → serialize to string → encrypt if enabled → fragment if P2P + → wrap each payload string in an envelope → JSON → transport +Receive: transport → JSON envelope → reassemble if P2P → decrypt if enabled + → decode text/image/files → update clipboard +``` + +### Reproducible encryption test vector + +Use this fixed nonce **only in a test**; production encryption must generate a +fresh nonce. These values let a client in another language check the two password +derivations and AES-GCM encoding independently of a running server. + +| Input | Value | +| --- | --- | +| Username | `alice` | +| Original password | `correct horse battery staple` | +| Extra salt | Empty string | +| Iterations | `664937` | +| Plaintext | `Hello 👋` (UTF-8, no trailing newline) | +| Nonce, hexadecimal | `000102030405060708090a0b0c0d0e0f` | + +Expected login SHA3-512 digest: + +```text +4b65d7b7acc886f9add07db3a5d42bf0032fe0109a1fd56f623c7093e8a59689f9246918a4f388034ddf393231eaba0742b3dc1840e4556270a729ce56098f35 +``` + +Expected PBKDF2 key, hexadecimal (32 bytes): + +```text +c6ffb459a9770f48246286649e2a7c5ed604796c0bb5319fb9064bfacb2595f5 +``` + +Expected inner encryption object (JSON key order/whitespace may differ): + +```json +{ + "nonce": "AAECAwQFBgcICQoLDA0ODw==", + "ciphertext": "IKDCfNQa/57thQ==", + "tag": "AaeI3kpRaOCTKz8b/Pusmw==" +} +``` + +Serialize this object into a string for `payload`; do not send it as an object. + +### Echo and clipboard-loop suppression + +The server has no deduplication or conflict resolution. Official clients track a +hash of the most recent serialized plaintext payload before send and before +applying received content. Desktop uses xxHash64; mobile uses xxHash32. These hashes +are local and are not sent, so an alternative client need not use the same hash +function. Compare content before encryption: random nonces make ciphertext change +on every encryption. Update your local suppression state before writing the OS +clipboard and avoid immediately retransmitting the received value. + +### Minimal authenticated P2S send/receive example + +Install `requests`, `websocket-client`, and `pycryptodome` in your own Python +virtual environment. Run the login example first, then the encryption helper +block, then this block in the same script. It sends one encrypted text update +and prints the first received text update (usually its own echo). This is a +protocol smoke test, not an OS clipboard monitor. All participating clients must +use the same encryption settings; change `cipher_enabled` and derivation parameters +here to match them. The example rejects P2P deployments instead of speaking the +wrong protocol. + +```python +import json +import time +from urllib.parse import urlsplit, urlunsplit +import websocket + + +def authenticated_json(path): + response = session.get(base + path, allow_redirects=False, timeout=15) + if response.status_code != 200: + raise RuntimeError(f"{path}: HTTP {response.status_code}") + if "application/json" not in response.headers.get("Content-Type", ""): + raise RuntimeError(f"{path}: expected JSON; check session") + return response.json() + + +if authenticated_json("/server-mode")["mode"] != "P2S": + raise RuntimeError("This example requires a P2S server") +max_size = authenticated_json("/max-size")["maxsize"] +cipher_enabled = True +key = derive_key(username, raw_password) if cipher_enabled else None +parts = urlsplit(base.rstrip("/")) +ws_url = urlunsplit(("wss" if parts.scheme == "https" else "ws", + parts.netloc, parts.path + "/clipsocket", "", "")) +# Ask requests to select cookies with the correct path/domain/Secure scope. +upgrade = session.prepare_request(requests.Request("GET", base + "/clipsocket")) +cookie_header = upgrade.headers.get("Cookie", "") +ws = websocket.create_connection( + ws_url, cookie=cookie_header, + origin=f"{parts.scheme}://{parts.netloc}", timeout=15, +) + + +def send_frame(command, headers, body=""): + headers = {**headers, "content-length": str(len(body.encode("utf-8")))} + wire = command + "\n" + "\n".join(f"{k}:{v}" for k, v in headers.items()) + ws.send(wire + "\n\n" + body + "\x00") + + +def frames(): + # Incremental parser for this example's LF-delimited STOMP 1.1 frames. + # A production STOMP library should also handle CRLF and header escaping. + buffer = b"" + while True: + chunk = ws.recv() + if not chunk: + raise RuntimeError("WebSocket closed") + buffer += chunk.encode("utf-8") if isinstance(chunk, str) else chunk + while True: + buffer = buffer.lstrip(b"\n") # Ignore idle heartbeat bytes. + split = buffer.find(b"\n\n") + if split < 0: + break + lines = buffer[:split].decode("utf-8").split("\n") + headers = dict(line.split(":", 1) for line in lines[1:]) + start = split + 2 + if "content-length" in headers: + length = int(headers["content-length"]) + if length < 0: + raise RuntimeError("Invalid STOMP content-length") + end = start + length + if len(buffer) <= end: + break + if buffer[end] != 0: + raise RuntimeError("Missing STOMP terminator") + else: + end = buffer.find(b"\x00", start) + if end < 0: + break + body = buffer[start:end].decode("utf-8") + buffer = buffer[end + 1:] + yield lines[0], headers, body + + +try: + send_frame("CONNECT", {"accept-version": "1.1", "host": parts.hostname, + "heart-beat": "0,20000"}) + incoming = frames() + command, headers, body = next(incoming) + if command != "CONNECTED": + raise RuntimeError(f"Expected CONNECTED, received {command}: {body}") + send_frame("SUBSCRIBE", {"id": "clipboard-0", + "destination": "/user/queue/cliptext", "ack": "auto"}) + plaintext = "Hello from an alternative client 👋" + if len(plaintext.encode("utf-8")) > max_size: + raise RuntimeError("Text exceeds server clipboard limit") + payload = encrypt_payload(plaintext, key) if cipher_enabled else plaintext + send_frame("SEND", {"destination": "/app/cliptext", + "content-type": "application/json"}, + json.dumps({"payload": payload, "type": "text"})) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + ws.settimeout(max(0.1, deadline - time.monotonic())) + command, headers, body = next(incoming) + if command == "ERROR": + raise RuntimeError(body) + if command == "MESSAGE": + envelope = json.loads(body) + if envelope.get("type", "text") == "text": + value = envelope["payload"] + print(decrypt_payload(value, key) if cipher_enabled else value) + break + else: + raise RuntimeError("No text update received") +finally: + ws.close() +``` + +This script closes the socket but leaves the HTTP login valid. To end that login, +use `session.post(base + "/logout", allow_redirects=False, timeout=15)` with the +CSRF header already set by the login example. For a continuously running client, +use a complete STOMP library, negotiated heartbeat deadlines, bounded input +buffers, retry/backoff, and the clipboard-loop suppression described above. + +## Size limits and deployment configuration + +`GET /max-size` reports the configured base size in bytes; default is `1048576`. +`GET /max-message-size` reports the same size in MiB, **floored to two decimal +places**. Use the byte value for calculations, especially for small limits. + +For P2S, official clients check text UTF-8 bytes, encoded image file bytes, or +cumulative raw file bytes against the base limit before serialization. The server +sets WebSocket text/binary buffer limits, STOMP message/buffer limits, and Jackson's +JSON string limit to **twice the base size** to allow encoding overhead. Its STOMP +send time limit is 200000 ms. It does not separately reject a decoded clipboard +payload at the base limit in the message handler. JSON escaping, Base64, encryption, +filenames, and framing can still exceed the transport ceiling. Sending near the +limit can close a socket rather than return an HTTP `413` or structured JSON error. +P2S has no application-level fragment reassembly; do not reuse P2P fragment envelopes +to bypass its limit when communicating with official clients. + +For P2P, these base-size discovery endpoints still return configured values, but +clipboard data bypasses the server. Official desktop switches its server clipboard +limit to `-1` in this mode and relies on local limits. The P2S buffer configuration +is conditional and does not configure the P2P signaling endpoint; do not infer its +maximum SDP size from `/max-size`. Apply your own transfer/buffer limits and account +for runtime, proxy, and WebRTC message limits. + +Client-relevant server settings (defaults from this tree): + +| Environment variable | Default | Effect | +| --- | --- | --- | +| `CC_PORT` | `8080` | HTTP listening port | +| `CC_P2P_ENABLED` | `false` | Selects P2P instead of P2S | +| `CC_ALLOWED_ORIGINS` | `*` | WebSocket allowed origin value; not HTTP CORS | +| `CC_MAX_MESSAGE_SIZE_IN_MiB` | `1` | Base message size when byte override is not positive; nonpositive MiB falls back to 1 | +| `CC_MAX_MESSAGE_SIZE_IN_BYTES` | `0` | Positive value overrides MiB setting | +| `CC_P2P_STUN_URL` | `stun:stun.l.google.com:19302` | P2P ICE discovery URL | +| `CC_MAX_WS_GLOBAL_CONNECTIONS` | `-1` | P2P global connection cap; `-1` unlimited | +| `CC_MAX_WS_CONNECTIONS_PER_USER` | `-1` | P2P per-user connection cap; `-1` unlimited | +| `CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC` | `-1` | P2P token bucket; nonpositive disables | +| `CC_SESSION_TIMEOUT` | `525960m` | HTTP session inactivity timeout | +| `CC_SESSION_PERSISTENCE_ENABLED` | `false` | Opt-in JDBC sessions, H2/PostgreSQL | +| `CC_SIGNUP_ENABLED` | `false` | Public registration and captcha | +| `CC_CAPTCHA_DIFFICULTY_PERCENT` | `100` | Captcha presentation difficulty, clamped to 0–100 | +| `CC_MAX_USER_ACCOUNTS` | `-1` | Total account cap including admin; `-1` unlimited | +| `CC_ACCOUNT_PURGE_TIMEOUT_SECONDS` | `-1` | Inactive non-admin cleanup at startup; negative disables | +| `CC_MAX_UNIQUE_IP_ATTEMPTS` | `15` | Brute-force tracker unique-IP threshold | +| `CC_MAX_ATTEMPTS_PER_IP` | `30` | Brute-force tracker per-IP threshold | +| `CC_LOCK_TIMEOUT_SECONDS` | `60` | Brute-force lockout base duration | +| `CC_LOCK_TIMEOUT_SCALING_FACTOR` | `2` | Per-IP lock duration scaling | +| `CC_EXTERNAL_BROKER_ENABLED` | `false` | P2S external STOMP broker relay | +| `CC_BROKER_HOST` / `CC_BROKER_PORT` | `localhost` / `61613` | Backend broker connection; clients still use `/clipsocket` | +| `CC_BROKER_USERNAME` / `CC_BROKER_PASSWORD` | `admin` / `admin` | Server-to-broker credentials, not client HTTP credentials | +| `CC_DONATIONS_ENABLED` | `false` | Donation link behavior | + +Database, logging, and BFA cache settings are operator settings documented in the +[server configuration guide](../README.md#-server-configuration) and +[application properties](ClipCascade_Backend/src/main/resources/application.properties). +Clients cannot change server configuration through an API endpoint. Treat settings +as deployment-specific; most are not exposed by the small discovery API. + +Brute-force failures/disabled accounts normally reach the same login error flow; +there is no client-facing structured lockout duration or `Retry-After` contract. +Stop automatic bad-credential retries and let the user resolve credentials or +ask an admin to inspect/unlock the account. + +## Recovery and troubleshooting + +Reconnect with backoff and jitter. Revalidate the HTTP session after transport +failure or sleep. If authenticated, rediscover mode/limits as needed and recreate +the socket; if unauthenticated, obtain a new login CSRF token, authenticate, and +replace cookies and the authenticated CSRF token. Never keep reconnecting with a +known-revoked cookie. Deliberate logout must disable automatic reconnection. + +| Symptom | Checks / action | +| --- | --- | +| A JSON endpoint returns HTML or a redirect | Disable automatic redirects during diagnosis; check `/whoami`, cookie scope, and session expiry. A followed `200` login page is not success. | +| Login redirects to `?error` | Check exact username, lowercase SHA3-512 of original UTF-8 password, enabled status, and lockout. Do not double-hash the digest. | +| Mutation returns `403` | Fetch a fresh CSRF token for the same session and use its named header; admin session-logout routes also return `403` for non-admins. | +| Admin route returns `400 Forbidden` | Verify `/whoami` reports `ADMIN`; most controller checks use `400`, not `403`. | +| WebSocket upgrade fails | Confirm mode/path, cookie, origin, proxy Upgrade support, TLS trust, and authentication. A public `/health` success does not establish a valid login. | +| STOMP connects but receives nothing | Wait for CONNECTED; subscribe to `/user/queue/cliptext`; send to `/app/cliptext`; compare usernames and check encoding settings. | +| P2S disconnects only on large clipboard updates | Measure serialized UTF-8 frame size, Base64/GCM overhead, broker/proxy limits, and `/max-size`. | +| P2P peer list appears but channel never opens | Check initiator ordering, SDP/candidate exchange, candidate timing, STUN/network reachability, and NAT constraints. Signaling presence is not channel readiness. | +| P2P negotiation intermittently stalls | Check signaling rate-limit drops, stale peer IDs, and conflicting recovery offers. | +| P2P disconnects after idle time | Respond to WebSocket ping with pong; data-channel traffic cannot satisfy signaling heartbeat checks. | +| Decryption/JSON errors | Align encryption enabled, original password, username, extra salt, and iterations; check Base64 fields and ciphertext/tag splitting. | +| Partial files or transfers never finish | Enforce ordered UTF-8-safe fragmentation, same metadata per transfer, complete reassembly before decrypt, and avoid interleaved transfers. | +| Clipboard repeats indefinitely | Suppress local events from received content and the sender's P2S echo, using plaintext comparisons. | +| Renamed/deleted username cannot be registered again | User-info records can reserve a name until startup purges records marked for deletion. | + +No catch-up endpoint exists. A reconnect can miss updates while offline; choose +an explicit local policy for whether to send the current clipboard after reconnect +instead of assuming the server holds the latest value. + +## Interoperability acceptance checklist + +Use two sessions on the same account, plus a separate account to check routing. +Run against both server modes and, where relevant, against desktop and mobile. +These are implementation acceptance checks, not claims of automated test coverage. + +- [ ] Login succeeds with a digest and fails with an incorrect credential; JSON + checks detect redirects/HTML and CSRF tokens are refreshed after re-login. +- [ ] Signup retains its captcha session, handles consumed challenges, and logs + in separately after successful registration. +- [ ] Same-account sessions exchange text; separate accounts do not receive the + application's user-targeted clipboard messages. +- [ ] Unicode, emoji, multiline text, and quotes survive JSON and STOMP byte counts. +- [ ] Encryption round-trips with official clients, including matching nondefault + salt/iterations; mismatched settings and altered tags fail without pasting. +- [ ] PNG images and multiple files decode correctly; filenames and file contents + survive the extra JSON/Base64 layers. +- [ ] P2S echo does not cause clipboard loops; reconnection resubscribes. +- [ ] P2P initiator ordering works with either peer joining first; peer departure, + sleep/resume, and fresh IDs remove stale connections. +- [ ] P2P payloads below/above 15360 bytes and multibyte characters at chunk + boundaries reassemble; keepalive messages never modify the clipboard. +- [ ] Incomplete/invalid fragments are bounded and discarded; local size limits + work even when sender metadata understates the actual size. +- [ ] Near-limit P2S data and excess P2P connections/rate limits fail gracefully. +- [ ] Password/status/name changes and admin logout revoke sessions; clients close + direct channels and do not automatically reconnect using stale credentials. +- [ ] Restart is handled with both in-memory and persistent sessions; a live HTTP + session never leads the client to reuse a dead socket or old subscription. + +## Implementation sources + +When changing the protocol, update this guide alongside these sources: + +- [HTTP routes and STOMP message handler](ClipCascade_Backend/src/main/java/com/acme/clipcascade/controller/ClipCascadeController.java) +- [Authentication and access rules](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java) +- [Session persistence and cookies](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SessionPersistenceConfiguration.java) +- [Session revocation](ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java) +- [Live socket revocation](ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java) +- [User validation](ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/UserValidator.java) +- [Pagination](ClipCascade_Backend/src/main/java/com/acme/clipcascade/utils/PagingUtility.java) +- [STOMP configuration](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java) +- [P2P signaling handler](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketHandler.java) +- [Configuration and size calculations](ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/ClipCascadeProperties.java) +- [Official desktop HTTP client](../ClipCascade_Desktop/src/utils/request_manager.py) +- [Desktop credential preparation](../ClipCascade_Desktop/src/core/application.py) +- [Desktop STOMP client](../ClipCascade_Desktop/src/stomp_ws/stomp_manager.py) +- [Desktop P2P and fragmentation](../ClipCascade_Desktop/src/p2p/p2p_manager.py) +- [Desktop encryption](../ClipCascade_Desktop/src/utils/cipher_manager.py) +- [Desktop image/file serialization](../ClipCascade_Desktop/src/clipboard/clipboard_manager.py) +- [Desktop configuration defaults](../ClipCascade_Desktop/src/core/config.py) +- [Mobile key derivation](../ClipCascade_Mobile/src/App.js) +- [Mobile encryption, WebRTC, and fragmentation](../ClipCascade_Mobile/src/StartForegroundService.js) diff --git a/ClipCascade_Server/ClipCascade_Backend/pom.xml b/ClipCascade_Server/ClipCascade_Backend/pom.xml index f0bd8cba..a2149946 100644 --- a/ClipCascade_Server/ClipCascade_Backend/pom.xml +++ b/ClipCascade_Server/ClipCascade_Backend/pom.xml @@ -52,7 +52,7 @@ org.springframework.session - spring-session-core + spring-session-jdbc org.springframework.boot diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/ClipCascadeApplication.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/ClipCascadeApplication.java index d2adbcfb..4bd988ba 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/ClipCascadeApplication.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/ClipCascadeApplication.java @@ -15,9 +15,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.session.SessionAutoConfiguration; import org.springframework.scheduling.annotation.EnableScheduling; -@SpringBootApplication +@SpringBootApplication(exclude = SessionAutoConfiguration.class) @EnableScheduling public class ClipCascadeApplication { 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..f5d84735 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 @@ -238,6 +238,9 @@ public class ClipCascadeProperties { @Value("${CC_SESSION_TIMEOUT:525960m}") private String sessionTimeout; + @Value("${app.session.persistence.enabled:false}") + private boolean sessionPersistenceEnabled; + /* * Server P2P enabled (default: false) * note: Ensure configuration is included in the application.properties file as @@ -277,6 +280,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. @@ -431,6 +452,10 @@ public String getSessionTimeout() { return sessionTimeout; } + public boolean isSessionPersistenceEnabled() { + return sessionPersistenceEnabled; + } + public boolean isP2pEnabled() { return p2pEnabled; } @@ -459,6 +484,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" + @@ -488,10 +528,13 @@ public String toString() { ",\n serverDbHibernateDialect='" + getServerDbHibernateDialect() + "'" + ",\n port='" + getPort() + "'" + ",\n sessionTimeout='" + getSessionTimeout() + "'" + + ",\n sessionPersistenceEnabled=" + isSessionPersistenceEnabled() + ",\n p2pEnabled='" + isP2pEnabled() + "'" + ",\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/P2PWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java index 74fcc796..acfe298a 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/P2PWebSocketConfig.java @@ -2,6 +2,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Configuration; +import com.acme.clipcascade.service.LiveWebSocketService; import org.springframework.lang.NonNull; import org.springframework.web.socket.config.annotation.EnableWebSocket; import org.springframework.web.socket.config.annotation.WebSocketConfigurer; @@ -15,17 +16,21 @@ public class P2PWebSocketConfig implements WebSocketConfigurer { private final P2PWebSocketHandler p2pWebSocketHandler; private final ClipCascadeProperties clipCascadeProperties; + private final LiveWebSocketService liveWebSockets; + public P2PWebSocketConfig( P2PWebSocketHandler p2pWebSocketHandler, - ClipCascadeProperties clipCascadeProperties) { + ClipCascadeProperties clipCascadeProperties, + LiveWebSocketService liveWebSockets) { this.p2pWebSocketHandler = p2pWebSocketHandler; + this.liveWebSockets = liveWebSockets; this.clipCascadeProperties = clipCascadeProperties; } @Override public void registerWebSocketHandlers(@NonNull WebSocketHandlerRegistry registry) { - registry.addHandler(p2pWebSocketHandler, "/p2psignaling") + registry.addHandler(liveWebSockets.decorate(p2pWebSocketHandler), "/p2psignaling") .setAllowedOrigins(clipCascadeProperties.getAllowedOrigins()); } } 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/config/SecurityConfiguration.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java index cfac3026..a55ac04e 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SecurityConfiguration.java @@ -1,5 +1,8 @@ package com.acme.clipcascade.config; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; +import org.springframework.session.security.SpringSessionBackedSessionRegistry; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationProvider; @@ -39,8 +42,10 @@ public class SecurityConfiguration { // SessionRegistry bean to store session information @Bean - public SessionRegistry sessionRegistry() { - return new SessionRegistryImpl(); + public SessionRegistry sessionRegistry(ObjectProvider repositories) { + JdbcIndexedSessionRepository repository = repositories.getIfAvailable(); + return repository == null ? new SessionRegistryImpl() + : new SpringSessionBackedSessionRegistry<>(repository); } // Ensures the SessionRegistry is notified of session lifecycle events @@ -50,7 +55,7 @@ public HttpSessionEventPublisher httpSessionEventPublisher() { } @Bean - public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain securityFilterChain(HttpSecurity http, SessionRegistry sessionRegistry) throws Exception { return http .authorizeHttpRequests((authorize) -> authorize .requestMatchers( @@ -78,7 +83,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session .sessionCreationPolicy(SessionCreationPolicy.ALWAYS) // Always create a new session .maximumSessions(-1) // Allow unlimited sessions - .sessionRegistry(sessionRegistry()) // Use the session registry + .sessionRegistry(sessionRegistry) // Use the session registry .expiredSessionStrategy(new CustomExpiredSession())) // Custom expired session strategy .build(); } diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SessionPersistenceConfiguration.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SessionPersistenceConfiguration.java new file mode 100644 index 00000000..13291ba8 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/SessionPersistenceConfiguration.java @@ -0,0 +1,117 @@ +package com.acme.clipcascade.config; + +import java.nio.charset.StandardCharsets; +import java.sql.Connection; +import java.sql.ResultSet; +import java.time.Duration; +import java.util.EnumSet; + +import javax.sql.DataSource; + +import jakarta.servlet.DispatcherType; + +import org.springframework.beans.factory.InitializingBean; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.DependsOn; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.core.io.ClassPathResource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.init.ResourceDatabasePopulator; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; +import org.springframework.session.web.http.CookieHttpSessionIdResolver; +import org.springframework.session.web.http.DefaultCookieSerializer; +import org.springframework.session.web.http.SessionRepositoryFilter; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +/** Opt-in session storage. Boot's automatic store selection is deliberately disabled. */ +@Configuration(proxyBeanMethods = false) +public class SessionPersistenceConfiguration { + + @Bean + InitializingBean sessionStorageInitializer(DataSource dataSource, + @Value("${app.session.persistence.enabled:false}") boolean enabled) { + return () -> { + try (Connection connection = dataSource.getConnection()) { + if (!enabled) { + // Do not let switching off and back on resurrect old authenticated sessions. + try (ResultSet tables = connection.getMetaData().getTables( + connection.getCatalog(), connection.getSchema(), null, new String[] { "TABLE" })) { + while (tables.next()) { + if ("SPRING_SESSION".equalsIgnoreCase(tables.getString("TABLE_NAME"))) { + new JdbcTemplate(dataSource).update("DELETE FROM SPRING_SESSION"); + break; + } + } + } + return; + } + String database = connection.getMetaData().getDatabaseProductName(); + String platform = switch (database) { + case "H2" -> "h2"; + case "PostgreSQL" -> "postgresql"; + default -> throw new IllegalStateException( + "Persistent sessions support H2 and PostgreSQL, found: " + database); + }; + // Use the framework's dialect-specific schema, made safe for repeated startups. + String schema = new ClassPathResource("org/springframework/session/jdbc/schema-" + + platform + ".sql").getContentAsString(StandardCharsets.UTF_8) + .replace("CREATE TABLE ", "CREATE TABLE IF NOT EXISTS ") + .replace("CREATE UNIQUE INDEX ", "CREATE UNIQUE INDEX IF NOT EXISTS ") + .replace("CREATE INDEX ", "CREATE INDEX IF NOT EXISTS ") + .replace("PRINCIPAL_NAME VARCHAR(100)", "PRINCIPAL_NAME VARCHAR(255)"); + new ResourceDatabasePopulator(new ByteArrayResource( + schema.getBytes(StandardCharsets.UTF_8))).execute(dataSource); + } + }; + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnProperty(name = "app.session.persistence.enabled", havingValue = "true") + static class PersistentSessions { + + @Bean + @DependsOn("sessionStorageInitializer") + JdbcIndexedSessionRepository sessionRepository(DataSource dataSource, + @Value("${server.servlet.session.timeout}") Duration timeout) { + // Use independent transactions on the same JDBC connection source for session writes, + // including when a request is changing an account in a JPA transaction. + TransactionTemplate transactions = new TransactionTemplate( + new DataSourceTransactionManager(dataSource)); + transactions.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + JdbcIndexedSessionRepository repository = new JdbcIndexedSessionRepository( + new JdbcTemplate(dataSource), transactions); + repository.setDefaultMaxInactiveInterval(timeout); + // Explicit logout must also revoke sessions configured with no inactivity limit. + repository.setDeleteSessionQuery("DELETE FROM SPRING_SESSION WHERE SESSION_ID = ?"); + return repository; + } + + @Bean + FilterRegistrationBean> + persistentSessionFilter(JdbcIndexedSessionRepository repository) { + DefaultCookieSerializer cookies = new DefaultCookieSerializer(); + cookies.setCookieName("JSESSIONID"); + cookies.setUseBase64Encoding(false); + cookies.setUseHttpOnlyCookie(true); + cookies.setSameSite("Lax"); + // Secure follows request.isSecure(), including a correctly configured HTTPS proxy. + CookieHttpSessionIdResolver resolver = new CookieHttpSessionIdResolver(); + resolver.setCookieSerializer(cookies); + SessionRepositoryFilter filter = + new SessionRepositoryFilter<>(repository); + filter.setHttpSessionIdResolver(resolver); + FilterRegistrationBean> registration = + new FilterRegistrationBean<>(filter); + registration.setOrder(SessionRepositoryFilter.DEFAULT_ORDER); + registration.setDispatcherTypes(EnumSet.of( + DispatcherType.REQUEST, DispatcherType.ASYNC, DispatcherType.ERROR)); + return registration; + } + } +} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java index 582b21f8..01627441 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/config/StompWebSocketConfig.java @@ -14,6 +14,7 @@ import org.springframework.web.socket.server.standard.ServletServerContainerFactoryBean; import com.acme.clipcascade.constants.ServerConstants; +import com.acme.clipcascade.service.LiveWebSocketService; @Configuration @EnableWebSocketMessageBroker @@ -22,8 +23,11 @@ public class StompWebSocketConfig implements WebSocketMessageBrokerConfigurer { private final ClipCascadeProperties clipCascadeProperties; - public StompWebSocketConfig(ClipCascadeProperties clipCascadeProperties) { + private final LiveWebSocketService liveWebSockets; + + public StompWebSocketConfig(ClipCascadeProperties clipCascadeProperties, LiveWebSocketService liveWebSockets) { this.clipCascadeProperties = clipCascadeProperties; + this.liveWebSockets = liveWebSockets; } @Override @@ -106,6 +110,7 @@ public ServletServerContainerFactoryBean servletServerContainerFactoryBean() { // STOMP WebSocket transport settings @Override public void configureWebSocketTransport(@SuppressWarnings("null") WebSocketTransportRegistration registration) { + registration.addDecoratorFactory(liveWebSockets::decorate); int maxMessageSizeInBytes = (int) clipCascadeProperties.getOverheadMaxMessageSizeInBytes(); /* 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..f4adea7b 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; } @@ -311,14 +316,19 @@ public ResponseEntity validateSession() { return ResponseEntityUtil.executeWithResponse(() -> "OK"); } + @GetMapping("/server-version") + public ResponseEntity getServerVersion() { + return ResponseEntityUtil.executeWithResponse( + () -> Collections.singletonMap("version", ServerConstants.APP_VERSION)); + } + @GetMapping("/admin/server-version") public ResponseEntity getServerVersion( @AuthenticationPrincipal UserPrincipal userPrincipal) { return ResponseEntityUtil.conditionalExecuteOrError( userPrincipal.isAdmin(), - () -> ResponseEntityUtil.executeWithResponse( - () -> Collections.singletonMap("version", ServerConstants.APP_VERSION)), + this::getServerVersion, "Forbidden"); } @@ -328,25 +338,25 @@ public ResponseEntity getLatestServerVersion( return ResponseEntityUtil.conditionalExecuteOrError( userPrincipal.isAdmin(), - () -> ResponseEntityUtil.executeWithResponse( - () -> { - try { - // get latest version - RestTemplate restTemplate = new RestTemplate(); - String versionJson = restTemplate.getForObject( - ServerConstants.VERSION_URL, - String.class); - - // convert json string to map - ObjectMapper objectMapper = new ObjectMapper(); - return objectMapper.readValue(versionJson, Map.class); - } catch (Exception e) { - throw new RuntimeException(e); - } - }), + this::getLatestServerVersion, "Forbidden"); } + @GetMapping("/latest-server-version") + public ResponseEntity getLatestServerVersion() { + return ResponseEntityUtil.executeWithResponse(() -> { + try { + RestTemplate restTemplate = new RestTemplate(); + String versionJson = restTemplate.getForObject( + ServerConstants.VERSION_URL, String.class); + ObjectMapper objectMapper = new ObjectMapper(); + return objectMapper.readValue(versionJson, Map.class); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + @GetMapping("/admin/websocket-stats") public ResponseEntity getWebSocketStats( @AuthenticationPrincipal UserPrincipal userPrincipal) { @@ -358,6 +368,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 +408,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( @@ -475,16 +564,25 @@ public ResponseEntity deleteUser( "Forbidden"); } + @DeleteMapping("/admin/logout-all-sessions") + public ResponseEntity logoutAllUserSessions( + @AuthenticationPrincipal UserPrincipal userPrincipal) { + if (!userPrincipal.isAdmin()) { + return ResponseEntity.status(403).body("Forbidden"); + } + return ResponseEntityUtil.executeWithResponse(sessionService::logoutAllUsers); + } + @DeleteMapping("/admin/logout-user-session") public ResponseEntity logoutUserSession( @AuthenticationPrincipal UserPrincipal userPrincipal, @RequestBody Map payload) { - return ResponseEntityUtil.conditionalExecuteOrError( - userPrincipal.isAdmin(), - () -> ResponseEntityUtil.executeWithResponse( - () -> sessionService.logoutAllSessions(payload.get("username"))), - "Forbidden"); + if (!userPrincipal.isAdmin()) { + return ResponseEntity.status(403).body("Forbidden"); + } + return ResponseEntityUtil.executeWithResponse( + () -> sessionService.logoutAllSessions(payload.get("username"))); } @DeleteMapping("/logout-session") @@ -544,7 +642,7 @@ public ResponseEntity updatePassword( return ResponseEntityUtil.buildResponse( facadeUserService.updatePassword( userPrincipal.getUsername(), - payload.get("newPassword")) != null, + payload.get("newPassword"), sessionService) != null, "Password updated successfully", "Invalid user or password"); } @@ -560,7 +658,7 @@ public ResponseEntity updateUserPassword( () -> ResponseEntityUtil.buildResponse( facadeUserService.updatePassword( payload.get("username"), - payload.get("newPassword")) != null, + payload.get("newPassword"), sessionService) != null, "Password updated successfully", "Invalid user or password"), "Forbidden"); 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/model/UserPrincipal.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserPrincipal.java index b3ba55f0..dd2a79a9 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserPrincipal.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/model/UserPrincipal.java @@ -1,8 +1,10 @@ package com.acme.clipcascade.model; +import java.io.Serial; import java.util.Collection; import java.util.Collections; +import org.springframework.security.core.CredentialsContainer; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; @@ -10,17 +12,26 @@ import com.acme.clipcascade.constants.RoleConstants; import com.acme.clipcascade.service.BruteForceProtectionService; -public class UserPrincipal implements UserDetails { +public class UserPrincipal implements UserDetails, CredentialsContainer { - private Users user; + @Serial + private static final long serialVersionUID = 1L; - private final BruteForceProtectionService bruteForceProtectionService; + private final String username; + private final String role; + private final boolean enabled; + // Neither password hashes nor application services belong in persisted sessions. + private transient String password; + private final transient BruteForceProtectionService bruteForceProtectionService; public UserPrincipal( Users user, BruteForceProtectionService bruteForceProtectionService) { - this.user = user; + this.username = user.getUsername(); + this.role = user.getRole(); + this.enabled = user.getEnabled(); + this.password = user.getPassword(); this.bruteForceProtectionService = bruteForceProtectionService; } @@ -28,28 +39,34 @@ public UserPrincipal( public boolean isAccountNonLocked() { // validate attempt using brute force protection - return bruteForceProtectionService.recordAndValidateAttempt(user.getUsername()); + return bruteForceProtectionService == null + || bruteForceProtectionService.recordAndValidateAttempt(username); } @Override public boolean isEnabled() { - return user.getEnabled(); + return enabled; } @Override public String getPassword() { - return user.getPassword(); + return password; } @Override public String getUsername() { - return user.getUsername(); + return username; } @Override public Collection getAuthorities() { // Return a collection of roles - return Collections.singleton(new SimpleGrantedAuthority(user.getRole())); + return Collections.singleton(new SimpleGrantedAuthority(role)); + } + + @Override + public void eraseCredentials() { + this.password = null; } public boolean isAdmin() { 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..c60d2c3e 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; @@ -103,13 +110,18 @@ public boolean deleteUser(String username, SessionService sessionService) { return userService.deleteUser(username); } - public Users updatePassword(String username, String newPassword) { + public Users updatePassword(String username, String newPassword, SessionService sessionService) { if (!UserValidator.isValidUsername(username) || !UserValidator.isValidPassword(newPassword)) { return null; } + if (!userService.userExists(username)) { + return null; + } + sessionService.logoutAllSessions(username); + userInfoService.setPasswordChangeTime(username, TimeUtility.getCurrentTimeInSeconds()); return userService.updatePassword(username, newPassword); @@ -132,8 +144,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 +162,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/LiveWebSocketService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java new file mode 100644 index 00000000..75ba2322 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/LiveWebSocketService.java @@ -0,0 +1,64 @@ +package com.acme.clipcascade.service; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.web.socket.CloseStatus; +import org.springframework.web.socket.WebSocketHandler; +import org.springframework.web.socket.WebSocketSession; +import org.springframework.web.socket.handler.WebSocketHandlerDecorator; + +/** Tracks local transports so revoking a login also disconnects its live sockets. */ +@Service +public class LiveWebSocketService { + private static final Logger logger = LoggerFactory.getLogger(LiveWebSocketService.class); + private final Map sessions = new ConcurrentHashMap<>(); + + public WebSocketHandler decorate(WebSocketHandler handler) { + return new WebSocketHandlerDecorator(handler) { + @Override + public void afterConnectionEstablished(WebSocketSession session) throws Exception { + sessions.put(session.getId(), session); + try { + super.afterConnectionEstablished(session); + } catch (Exception ex) { + sessions.remove(session.getId(), session); + throw ex; + } finally { + if (!session.isOpen()) { + sessions.remove(session.getId(), session); + } + } + } + + @Override + public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception { + sessions.remove(session.getId(), session); + super.afterConnectionClosed(session, status); + } + }; + } + + public void disconnectUser(String username) { + sessions.values().stream() + .filter(session -> session.getPrincipal() != null + && username.equals(session.getPrincipal().getName())) + .forEach(this::disconnect); + } + + public void disconnectAll() { + sessions.values().forEach(this::disconnect); + } + + private void disconnect(WebSocketSession session) { + try { + session.close(new CloseStatus(1008, "Session revoked")); + } catch (IOException ex) { + logger.warn("Could not close a revoked WebSocket connection", ex); + } + } +} 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/SessionService.java b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java index d53da74e..afeae44a 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/java/com/acme/clipcascade/service/SessionService.java @@ -2,6 +2,10 @@ import java.util.List; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.session.jdbc.JdbcIndexedSessionRepository; + import org.springframework.security.core.session.SessionInformation; import org.springframework.security.core.session.SessionRegistry; import org.springframework.stereotype.Service; @@ -15,13 +19,34 @@ public class SessionService { private final SessionRegistry sessionRegistry; private final UserService userService; + private final JdbcIndexedSessionRepository persistentSessions; + private final JdbcTemplate jdbc; + private final LiveWebSocketService liveWebSockets; public SessionService( SessionRegistry sessionRegistry, - UserService userService) { + UserService userService, + ObjectProvider repositories, + JdbcTemplate jdbc, LiveWebSocketService liveWebSockets) { this.sessionRegistry = sessionRegistry; this.userService = userService; + this.persistentSessions = repositories.getIfAvailable(); + this.jdbc = jdbc; + this.liveWebSockets = liveWebSockets; + } + + public String logoutAllUsers() { + if (persistentSessions != null) { + // Includes anonymous sessions and the administrator making the request. + jdbc.update("DELETE FROM SPRING_SESSION"); + } else { + for (Object principal : sessionRegistry.getAllPrincipals()) { + sessionRegistry.getAllSessions(principal, false).forEach(SessionInformation::expireNow); + } + } + liveWebSockets.disconnectAll(); + return "All users have been logged out of all active sessions."; } // logout all sessions for a specific user/username @@ -35,6 +60,14 @@ public String logoutAllSessions(String username) { throw new EntityNotFoundException("User not found"); } + if (persistentSessions != null) { + var sessions = persistentSessions.findByPrincipalName(username); + sessions.keySet().forEach(persistentSessions::deleteById); + liveWebSockets.disconnectUser(username); + return sessions.isEmpty() ? "No active sessions found for username: " + username + : "User '" + username + "' has been logged out of all active sessions."; + } + // Iterate over all principals in the SessionRegistry List principals = sessionRegistry.getAllPrincipals(); boolean foundUser = false; @@ -53,6 +86,8 @@ public String logoutAllSessions(String username) { } } + liveWebSockets.disconnectUser(username); + if (!foundUser) { return "No active sessions found for username: " + username; } 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/META-INF/additional-spring-configuration-metadata.json b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json index 765651e2..9ea27525 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -8,5 +8,11 @@ "name": "app.bfa.cache.enabled", "type": "java.lang.Boolean", "description": "Enables the Brute Force Protection Tracker cache" + }, + { + "name": "app.session.persistence.enabled", + "type": "java.lang.Boolean", + "defaultValue": false, + "description": "Enables persistent HTTP session storage in the database" } -]} \ No newline at end of file +]} diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties index eb3fb770..ff02a726 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/application.properties @@ -22,6 +22,7 @@ spring.jpa.properties.hibernate.dialect=${CC_SERVER_DB_HIBERNATE_DIALECT:org.hib # --------------------------------------- server.port=${CC_PORT:8080} server.servlet.session.timeout=${CC_SESSION_TIMEOUT:525960m} +app.session.persistence.enabled=${CC_SESSION_PERSISTENCE_ENABLED:false} # --------------------------------------- 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..913f98f1 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; } @@ -131,12 +131,20 @@ label small { display: none; } .footer { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; flex-shrink: 0; background-color: #222; color: #ccc; padding: 10px; text-align: left; } +.footer #server-version { + margin-left: auto; + text-align: right; +} .footer .footer-links { margin: 0; padding: 0; @@ -152,4 +160,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/css/theme.css b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/theme.css new file mode 100644 index 00000000..bc86b24c --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/css/theme.css @@ -0,0 +1,279 @@ +/* One palette for the custom pages and all three Bootstrap versions. */ +:root { + color-scheme: light; + --cc-bg: #f4f6f9; + --cc-surface: #fff; + --cc-inset: #f7f9fc; + --cc-hover: #edf2f8; + --cc-text: #263244; + --cc-muted: #66758a; + --cc-border: #dce3ed; + --cc-link: #2563b8; + --cc-accent: #2866c4; + --cc-on-accent: #fff; + --cc-shadow: 0 8px 30px rgb(25 42 70 / 5%); + --cc-toggle-thumb: #fff; +} +html[data-bs-theme="dark"] { + color-scheme: dark; + --cc-bg: #11151c; + --cc-surface: #1a202a; + --cc-inset: #151b24; + --cc-hover: #242e3c; + --cc-text: #e2e8f2; + --cc-muted: #9caac0; + --cc-border: #303c4e; + --cc-link: #91baff; + --cc-accent: #3877d7; + --cc-shadow: 0 8px 30px rgb(0 0 0 / 16%); + --cc-toggle-thumb: #344560; +} +html, +html body { + background-color: var(--cc-bg); + color: var(--cc-text); +} +body .container { + background-color: transparent; +} +body.dashboard-page > .container, +body .section-container, +body .modal-content { + color: var(--cc-text); + background: var(--cc-surface); + border: 1px solid var(--cc-border); + border-radius: 12px; + box-shadow: var(--cc-shadow); +} +body.dashboard-page > .container { + padding: 24px; + width: calc(100% - 32px); +} +body .section-container { + padding: 20px; +} +body a { color: var(--cc-link); } +body a:hover { color: var(--cc-link); } +body hr { border-color: var(--cc-border); opacity: 1; } +body label small, +body .page-info, +body .sort-indicator, +body .text-secondary, +body .text-muted { + color: var(--cc-muted) !important; +} +body .sort-indicator::after { color: var(--cc-muted) !important; } +body .form-control, +body .form-select { + color: var(--cc-text); + background-color: var(--cc-inset); + border-color: var(--cc-border); + border-radius: 7px; + box-shadow: none; +} +body .form-control::placeholder { color: var(--cc-muted); opacity: 1; } +body .form-control:focus, +body .form-select:focus { + color: var(--cc-text); + background-color: var(--cc-inset); + border-color: var(--cc-link); + box-shadow: 0 0 0 3px rgb(91 149 237 / 18%); +} +body .form-control:disabled, +body .form-control[readonly] { + background-color: var(--cc-hover); + color: var(--cc-muted); +} +body .btn { border-radius: 7px; } +body .btn-default, +body .btn-outline-secondary { + color: var(--cc-text); + border-color: var(--cc-border); + background-color: var(--cc-surface); +} +body .btn-default:disabled, +body .btn-default:disabled:hover, +body .btn-default:disabled:focus, +body .btn-outline-secondary:disabled { + color: var(--cc-muted); + background: var(--cc-inset); + border-color: var(--cc-border); +} +body .btn-default:hover, +body .btn-default:focus, +body .btn-default:active, +body .btn-outline-secondary:hover, +body .btn-outline-secondary:focus, +body .btn-outline-secondary:active { + color: var(--cc-text); + border-color: var(--cc-muted); + background-color: var(--cc-hover); +} +body .btn-primary, +body button.primary { + background-color: var(--cc-accent); + border-color: var(--cc-accent); + color: var(--cc-on-accent); +} +body .btn-primary:hover, +body button.primary:hover { background-color: #235bb0; border-color: #235bb0; } +body .table { + color: var(--cc-text); + background-color: var(--cc-surface); + --bs-table-color: var(--cc-text); + --bs-table-bg: var(--cc-surface); + --bs-table-border-color: var(--cc-border); + --bs-table-striped-bg: var(--cc-inset); + --bs-table-striped-color: var(--cc-text); + --bs-table-hover-bg: var(--cc-hover); + --bs-table-hover-color: var(--cc-text); +} +body .table-bordered, +body .table > thead > tr > th, +body .table > tbody > tr > td, +body #conversation th, +body #conversation td { + border-color: var(--cc-border); +} +body .table-striped > tbody > tr:nth-of-type(odd) { background: var(--cc-inset); } +body .table thead th { + background: var(--cc-hover); + color: var(--cc-text); +} +body #websocket-stats-output, +body #server-time-output { + background: var(--cc-inset); + color: var(--cc-text); + border: 1px solid var(--cc-border); + border-radius: 7px; +} +body .navbar-inverse { + background: var(--cc-surface); + border: 0; + border-bottom: 1px solid var(--cc-border); +} +body .navbar-inverse .navbar-brand { color: var(--cc-text); } +body .navbar-inverse .navbar-text, +body .navbar-inverse .navbar-nav > li > a { color: var(--cc-muted); } +body .navbar-inverse .navbar-nav > li > a:hover, +body .navbar-inverse .navbar-nav > li > a:focus { + color: var(--cc-text); + background: var(--cc-hover); +} +body .footer { + background: var(--cc-surface); + border-top: 1px solid var(--cc-border); + color: var(--cc-muted); + box-shadow: none; +} +body .footer .footer-links a, +body .footer-left-container a { color: var(--cc-muted); } +body .footer a:hover { color: var(--cc-link); } +body .footer-right-container { color: var(--cc-muted); } +body .captcha-reload-btn { color: var(--cc-link); } +body .modal-close { color: var(--cc-muted); } +/* The icons stay unboxed; invert only the monochrome artwork. */ +[data-bs-theme="dark"] .footer-middle-container img[src$="apple.png"], +[data-bs-theme="dark"] .footer-middle-container img[src$="github.png"] { + filter: invert(1); +} +[data-bs-theme="dark"] .footer-middle-container img[src$="github.png"] { mix-blend-mode: screen; } +[data-bs-theme="dark"] .alert-success { background: #18382c; color: #a3e2bf; border-color: #2e6048; } +[data-bs-theme="dark"] .alert-danger { background: #3c222b; color: #f4b2bd; border-color: #6c3747; } +[data-bs-theme="dark"] .alert-warning { background: #39311f; color: #edd097; border-color: #66552d; } +[data-bs-theme="dark"] .alert-info { background: #1c3046; color: #b3d6f6; border-color: #345574; } + +/* A single sun/moon switch, placed with the existing page header. */ +body .theme-toggle { + position: relative; + display: inline-flex; + align-items: center; + justify-content: space-between; + flex-shrink: 0; + width: 62px; + height: 34px; + padding: 6px; + border: 1px solid var(--cc-border); + border-radius: 20px; + background: var(--cc-inset); + color: var(--cc-muted); + line-height: 1; + cursor: pointer; + vertical-align: middle; +} +.theme-toggle::before { + content: ""; + position: absolute; + top: 3px; + left: 3px; + width: 26px; + height: 26px; + border-radius: 50%; + background: var(--cc-toggle-thumb); + box-shadow: 0 1px 4px rgb(0 0 0 / 15%); +} +.theme-toggle svg { position: relative; width: 18px; height: 18px; } +.theme-toggle .theme-sun { color: #a66a13; } +.theme-toggle[aria-checked="true"]::before { transform: translateX(28px); } +.theme-toggle[aria-checked="true"] .theme-moon { color: #b7ceff; } +body .theme-toggle:hover { border-color: var(--cc-muted); } +body .theme-toggle:focus-visible { outline: 3px solid var(--cc-link); outline-offset: 3px; } +.theme-corner { position: absolute; top: 18px; right: 20px; } +.theme-header { position: relative; padding: 0 78px; } +.theme-header .theme-toggle { position: absolute; top: 0; right: 0; } +.theme-nav-item { padding: 8px 15px; } +.auth-page .app-title { padding: 0 70px; } +.auth-page .form-signin, +.auth-page .form-signup, +.logout-page .logout-form { + width: 100%; + max-width: 380px; + margin: 7vh auto 0; + padding: 28px; + border: 1px solid var(--cc-border); + border-radius: 14px; + background: var(--cc-surface); + box-shadow: var(--cc-shadow); +} +.auth-page .form-signin-heading, +.auth-page .form-signup-heading { margin-bottom: 24px; font-size: 1.5rem; } +.auth-page .form-control { min-height: 44px; } +.signup-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 24px; +} +.auth-page .signup-header .form-signup-heading { margin: 0; } +.back-to-login { + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + width: 32px; + height: 32px; + border-radius: 8px; + color: var(--cc-muted); +} +.back-to-login svg { width: 22px; height: 22px; } +.back-to-login:hover { background: var(--cc-hover); color: var(--cc-text); } +.back-to-login:focus-visible { outline: 3px solid var(--cc-link); outline-offset: 2px; } +.logout-page .logout-form h2 { font-size: 1.6rem; line-height: 1.3; margin-bottom: 24px; } +.logout-page button.primary { border-radius: 7px; } +.auth-page .btn { font-size: 1rem; padding: 11px 16px; } +.auth-page { padding-bottom: 90px; } +@media (max-width: 640px) { + .auth-page { padding-bottom: 0; } + .auth-page .app-title { font-size: 26px; margin-top: 24px; } + .auth-page .form-signin, + .auth-page .form-signup { margin-top: 40px; padding: 24px; } + .auth-page .footer { position: static; margin-top: 40px; flex-wrap: wrap; gap: 16px; justify-content: center; } + .auth-page .footer-middle-container { flex-wrap: wrap; order: 3; flex-basis: 100%; gap: 12px; } + .auth-page .footer-middle-container a img { margin: 0; } + .theme-header { padding: 0 0 46px; } + .theme-header .theme-toggle { top: auto; bottom: 0; } + body.dashboard-page > .container { padding: 16px; } +} +@media (prefers-reduced-motion: no-preference) { + .theme-toggle::before { transition: transform 160ms ease; } +} 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..dab92717 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 @@ -14,6 +14,7 @@ const SELECTORS = { // Header / Common updateLink: "#update-link", + serverVersion: "#server-version", advanceLink: "#advance-link", changeUsernameLink: "#change-username-link", changePasswordLink: "#change-password-link", @@ -26,7 +27,11 @@ const SELECTORS = { // Admin Section adminSection: "#admin-section", loadUsersBtn: "#load-users-btn", + revokeAllSessionsBtn: "#revoke-all-sessions-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 +56,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", @@ -61,8 +67,9 @@ const ENDPOINTS = { websocketSend: "/app/cliptext", websocketReceive: "/user/queue/cliptext", adminLogoutUserSession: "/admin/logout-user-session", - serverVersion: "/admin/server-version", - latestServerVersion: "/admin/latest-server-version", + adminLogoutAllSessions: "/admin/logout-all-sessions", + serverVersion: "/server-version", + latestServerVersion: "/latest-server-version", serverMode: "/server-mode", stunUrl: "/stun-url", donationStatus: "/donation-status", @@ -85,10 +92,37 @@ let heartbeatIntervalId = null; // for 20s keep-alive on P2P let stunConfig = { iceServers: [] }; // will be loaded from /stun-url // Common +let monitoringRequested = false; +let monitoringGeneration = 0; +let reconnectTimer = null; +let connectionTimer = null; +let reconnectAttempts = 0; + let CSRF_TOKEN = null; let MAX_MESSAGE_SIZE = null; // in MiB let CSRF_HEADER_NAME = "X-CSRF-TOKEN"; +function compareReleaseVersions(latest, installed) { + const parse = (value) => { + if (typeof value !== "string" || !/^[0-9]+\.[0-9]+\.[0-9]+$/.test(value.trim())) { + throw new Error("Invalid release version"); + } + const parts = value.trim().split(".").map(Number); + if (!parts.every(Number.isSafeInteger)) { + throw new Error("Invalid release version"); + } + return parts; + }; + const remote = parse(latest); + const current = parse(installed); + for (let i = 0; i < remote.length; i++) { + if (remote[i] !== current[i]) { + return remote[i] > current[i] ? 1 : -1; + } + } + return 0; +} + /* ----------------------------------------- ============ ON DOCUMENT READY ========= ----------------------------------------- */ @@ -117,24 +151,28 @@ $(function () { showSection(SELECTORS.advanceLink); showSection(SELECTORS.changeUsernameLink); showSection(SELECTORS.adminSection); - - // check for server updates - fetchServerVersion() - .then((serverVersion) => { - fetchLatestServerVersion() - .then((latestServerVersion) => { - if (serverVersion.version !== latestServerVersion.server) { - $(SELECTORS.updateLink).text( - `🔄 Update (${serverVersion.version} ➞ ${latestServerVersion.server})` - ); - showSection(SELECTORS.updateLink); - } - }) - .catch(() => {}); - }) - .catch(() => {}); } + // Show the read-only server version to every signed-in user. + fetchServerVersion() + .then((serverVersion) => { + $(SELECTORS.serverVersion).text(`Version v${serverVersion.version}`); + fetchLatestServerVersion() + .then((latestServerVersion) => { + const comparison = compareReleaseVersions(latestServerVersion.server, serverVersion.version); + if (comparison === 0) { + $(SELECTORS.serverVersion).text(`Version v${serverVersion.version} (latest)`); + } else if (comparison > 0 && userData.role === "ADMIN") { + $(SELECTORS.updateLink).text( + `🔄 Update (${serverVersion.version} ➞ ${latestServerVersion.server})` + ); + showSection(SELECTORS.updateLink); + } + }) + .catch(() => {}); + }) + .catch(() => {}); + // Initialize event handlers initEventHandlers(); @@ -171,6 +209,7 @@ function initEventHandlers() { // Admin Section $(SELECTORS.loadUsersBtn).click(loadAllUsers); + $(SELECTORS.revokeAllSessionsBtn).click(revokeAllUserSessions); $(SELECTORS.openAddUserModalBtn).click(() => showModal(SELECTORS.addUserModal) ); @@ -186,14 +225,62 @@ function initEventHandlers() { ========== MODE DECISION ========= ----------------------------------------- */ function onConnectClick() { - $(SELECTORS.statusMessage).hide(); - if (CURRENT_MODE === "P2S") { - connectStomp(); - } else { - connectP2P(); + if (monitoringRequested) return; + monitoringRequested = true; + reconnectAttempts = 0; + const generation = ++monitoringGeneration; + updateMonitoringControls(false); + showStatusMessage("Connecting to monitoring…", "info"); + connectMonitoring(generation); +} + +async function connectMonitoring(generation) { + try { + // A failed WebSocket handshake does not expose HTTP authentication status. + // Probe the session before each attempt so expired logins stop retrying. + const user = await $.ajax({ url: ENDPOINTS.whoami, dataType: "json", cache: false }); + if (!monitoringRequested || generation !== monitoringGeneration) return; + if (!user || typeof user.username !== "string") { + monitoringSessionExpired(); + return; + } + if (CURRENT_MODE === "P2S") connectStomp(generation); + else await connectP2P(generation); + } catch (error) { + if (!monitoringRequested || generation !== monitoringGeneration) return; + if (error.status === 401 || error.status === 403 || error.status === 200) { + // Redirects to the HTML login page produce a JSON parse error with HTTP 200. + monitoringSessionExpired(); + } else { + scheduleMonitoringReconnect(generation); + } } } +function updateMonitoringControls(connected) { + $(SELECTORS.connectBtn).prop("disabled", monitoringRequested); + $(SELECTORS.disconnectBtn).prop("disabled", !monitoringRequested); + $(SELECTORS.sendBtn).prop("disabled", !connected); + if (connected) $(SELECTORS.conversationTable).show(); +} + +function scheduleMonitoringReconnect(generation) { + if (!monitoringRequested || generation !== monitoringGeneration || reconnectTimer !== null) return; + updateMonitoringControls(false); + const delay = Math.min(1000 * 2 ** Math.min(reconnectAttempts++, 5), 30000); + showStatusMessage(`Connection lost. Retrying in ${delay / 1000} seconds…`, "warning"); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + connectMonitoring(generation); + }, delay); +} + +function monitoringSessionExpired() { + onDisconnectClick(); + showStatusMessage("Your session expired or was revoked. Please log in again.", "warning"); + window.location.href = "/login?expired"; +} + function onSendClick() { const textVal = $("#ws_text").val() || ""; if (!textVal.trim()) return; @@ -210,11 +297,17 @@ function onSendClick() { } function onDisconnectClick() { - if (CURRENT_MODE === "P2S") { - disconnectStomp(); - } else { - disconnectP2P(); - } + monitoringRequested = false; + ++monitoringGeneration; + clearTimeout(reconnectTimer); + clearTimeout(connectionTimer); + reconnectTimer = connectionTimer = null; + disconnectStomp(); + disconnectP2P(); + updateMonitoringControls(false); + $(SELECTORS.conversationTable).hide(); + $(SELECTORS.conversationBody).empty(); + showStatusMessage("Monitoring disconnected.", "warning"); } /* ----------------------------------------- @@ -468,46 +561,48 @@ function fetchDonationStatus() { /* ----------------------------------------- ========== STOMP (P2S) ========== ----------------------------------------- */ -function connectStomp() { - const brokerURL = $(SELECTORS.brokerUrlInput).val(); - stompClient = new StompJs.Client({ - brokerURL, +function connectStomp(generation) { + const client = new StompJs.Client({ + brokerURL: $(SELECTORS.brokerUrlInput).val(), heartbeatIncoming: 20000, heartbeatOutgoing: 0, + connectionTimeout: 15000, + reconnectDelay: 0, // Both modes use the authenticated retry loop above. }); - - stompClient.onConnect = (frame) => { - setStompConnected(true); + stompClient = client; + const current = () => monitoringRequested && generation === monitoringGeneration && stompClient === client; + client.onConnect = () => { + if (!current()) return; + reconnectAttempts = 0; + updateMonitoringControls(true); showStatusMessage("Connected to WebSocket server (P2S).", "success"); - console.log("Connected (P2S): " + frame); - - // Subscribe to personal queue - stompClient.subscribe(ENDPOINTS.websocketReceive, (res) => { - displayIncomingMessage(JSON.parse(res.body)); + client.subscribe(ENDPOINTS.websocketReceive, (res) => { + if (current()) displayIncomingMessage(JSON.parse(res.body)); }); }; - - stompClient.onWebSocketError = (error) => { + client.onWebSocketClose = () => { + if (!current()) return; + stompClient = null; + client.deactivate(); + scheduleMonitoringReconnect(generation); + }; + client.onWebSocketError = (error) => { + if (current()) showStatusMessage("WebSocket connection failed. Waiting to retry…", "warning"); console.error("WebSocket error:", error); - showStatusMessage("WebSocket error. Check console for details.", "danger"); }; - - stompClient.onStompError = (frame) => { - console.error("STOMP error:", frame.headers["message"]); - console.error("Details:", frame.body); - showStatusMessage("STOMP error. Check console for details.", "danger"); + client.onStompError = () => { + if (!current()) return; + stompClient = null; + client.deactivate(); + scheduleMonitoringReconnect(generation); }; - - stompClient.activate(); + client.activate(); } function disconnectStomp() { - if (stompClient) { - stompClient.deactivate(); - setStompConnected(false); - showStatusMessage("Disconnected from WebSocket server (P2S).", "warning"); - console.log("Disconnected (P2S)"); - } + const client = stompClient; + stompClient = null; + if (client) client.deactivate(); } function sendTextToServerStomp(textVal) { @@ -518,91 +613,57 @@ function sendTextToServerStomp(textVal) { }); } -function setStompConnected(connected) { - $(SELECTORS.connectBtn).prop("disabled", connected); - $(SELECTORS.disconnectBtn).prop("disabled", !connected); - $(SELECTORS.conversationTable).toggle(connected); - if (!connected) { - $(SELECTORS.conversationBody).empty(); - } -} - /* ----------------------------------------- ========== P2P WEBRTC ========== ----------------------------------------- */ -function connectP2P() { +async function connectP2P(generation) { const signalingUrl = $(SELECTORS.brokerUrlInput).val(); - if (!signalingUrl) { - showStatusMessage("Signaling URL is required for P2P mode.", "danger"); - return; - } - - // first fetch STUN url (async), then connect - fetchStunUrl().then(() => { - // WebSocket for signaling - p2pSignalingWebSocket = new WebSocket(signalingUrl); - - p2pSignalingWebSocket.onopen = () => { - setP2PConnected(true); - showStatusMessage("Connected to Signaling server (P2P).", "success"); - // Start heartbeat(stay alive) every 20 seconds - /* - * heartbeatIntervalId = setInterval(() => { - * if ( - * p2pSignalingWebSocket && - * p2pSignalingWebSocket.readyState === WebSocket.OPEN - * ) { - * p2pSignalingWebSocket.send("\n"); - * } - * }, 20000); - */ - }; - - p2pSignalingWebSocket.onmessage = (evt) => { - const data = JSON.parse(evt.data); - handleSignalingMessage(data); - }; - - p2pSignalingWebSocket.onclose = () => { - console.log("Signaling WebSocket closed (P2P)."); - showStatusMessage("Disconnected from Signaling server (P2P).", "warning"); - }; - - p2pSignalingWebSocket.onerror = (err) => { - console.error("Signaling WebSocket error:", err); - showStatusMessage("Signaling error (P2P). Check console.", "danger"); - }; - }); + await fetchStunUrl(); + if (!monitoringRequested || generation !== monitoringGeneration) return; + const socket = new WebSocket(signalingUrl); + p2pSignalingWebSocket = socket; + const current = () => monitoringRequested && generation === monitoringGeneration && p2pSignalingWebSocket === socket; + connectionTimer = setTimeout(() => { + if (current()) socket.close(); + }, 15000); + socket.onopen = () => { + if (!current()) return; + clearTimeout(connectionTimer); + connectionTimer = null; + reconnectAttempts = 0; + updateMonitoringControls(true); + showStatusMessage("Connected to Signaling server (P2P).", "success"); + }; + socket.onmessage = (evt) => { + if (current()) handleSignalingMessage(JSON.parse(evt.data)); + }; + socket.onclose = () => { + if (!current()) return; + clearTimeout(connectionTimer); + connectionTimer = null; + p2pSignalingWebSocket = null; + closePeerConnections(); + scheduleMonitoringReconnect(generation); + }; + socket.onerror = () => { + if (current()) socket.close(); + }; } -function disconnectP2P() { - if (p2pSignalingWebSocket) { - p2pSignalingWebSocket.close(); - p2pSignalingWebSocket = null; - } - if (heartbeatIntervalId) { - clearInterval(heartbeatIntervalId); - heartbeatIntervalId = null; - } - for (const peerId in peerConnections) { - peerConnections[peerId].close(); - } +function closePeerConnections() { + for (const peerId in peerConnections) peerConnections[peerId].close(); peerConnections = {}; dataChannels = {}; myPeerId = null; - - // Only on explicit user disconnect do we hide/clear the conversation: - setP2PConnected(false); - showStatusMessage("Disconnected from P2P mesh.", "warning"); } -function setP2PConnected(connected) { - $(SELECTORS.connectBtn).prop("disabled", connected); - $(SELECTORS.disconnectBtn).prop("disabled", !connected); - $(SELECTORS.conversationTable).toggle(connected); - if (!connected) { - $(SELECTORS.conversationBody).empty(); - } +function disconnectP2P() { + const socket = p2pSignalingWebSocket; + p2pSignalingWebSocket = null; + if (socket) socket.close(); + if (heartbeatIntervalId) clearInterval(heartbeatIntervalId); + heartbeatIntervalId = null; + closePeerConnections(); } function handleSignalingMessage(msg) { @@ -789,6 +850,9 @@ function sendTextToPeers(textVal) { ========== COMMON MONITORING ========= ----------------------------------------- */ function displayIncomingMessage(message) { + // Peer keep-alives are connection traffic, not clipboard entries. + if (message && message._cc_keepalive === true) return; + const timestamp = new Date().toISOString().replace(/[-:.]/g, ""); const filename = `${timestamp}.txt`; const payload = message.payload || ""; @@ -902,43 +966,242 @@ 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, + }; + + 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); + }); + } - users.forEach((user) => { - const row = $(""); - row.append($("").text(user.username)); - row.append($("").text(user.enabled ? "Enabled" : "Disabled")); + 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)); + + const revokeBtn = $(""); + revokeBtn.click(() => { + if (!confirm(`Revoke all sessions for '${user.username}'?`)) return; + logoutUserFromAllDevices(user.username, () => { + if (user.username === $(SELECTORS.user).text()) window.location.href = "/login?expired"; + else alert(`Sessions revoked for '${user.username}'.`); + }); + }); + actionTd.append(editBtn).append(revokeBtn).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}'?`)) { @@ -961,6 +1224,16 @@ function deleteUser(username) { }); } +function revokeAllUserSessions() { + if (!confirm("Revoke sessions for every user? This also logs you out.")) return; + $.ajax({ + url: ENDPOINTS.adminLogoutAllSessions, + method: "DELETE", + success: () => { window.location.href = "/login?expired"; }, + error: () => alert("Failed to revoke all sessions."), + }); +} + function logoutUserFromAllDevices(username, successCallback) { $.ajax({ url: ENDPOINTS.adminLogoutUserSession, diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/theme.js b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/theme.js new file mode 100644 index 00000000..ef52bb86 --- /dev/null +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/assets/js/theme.js @@ -0,0 +1,37 @@ +(function () { + "use strict"; + + const systemTheme = window.matchMedia("(prefers-color-scheme: dark)"); + const root = document.documentElement; + + function applyTheme(dark) { + root.setAttribute("data-bs-theme", dark ? "dark" : "light"); + root.style.colorScheme = dark ? "dark" : "light"; + const toggle = document.getElementById("theme-toggle"); + if (toggle) { + toggle.setAttribute("aria-checked", String(dark)); + toggle.title = dark ? "Switch to light mode" : "Switch to dark mode"; + } + } + + // Start fresh from the browser/OS preference on every page load. + applyTheme(systemTheme.matches); + function followSystem(event) { + applyTheme(event.matches); + } + if (systemTheme.addEventListener) { + systemTheme.addEventListener("change", followSystem); + } else { + systemTheme.addListener(followSystem); + } + + document.addEventListener("DOMContentLoaded", function () { + const toggle = document.getElementById("theme-toggle"); + if (toggle) { + toggle.addEventListener("click", function () { + applyTheme(root.getAttribute("data-bs-theme") !== "dark"); + }); + } + applyTheme(root.getAttribute("data-bs-theme") === "dark"); + }); +})(); 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..6b678061 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/static/index.html @@ -1,6 +1,8 @@ + + ClipCascade @@ -13,8 +15,9 @@ + - + @@ -34,6 +37,16 @@ + + ClipCascade - Advance @@ -27,11 +29,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,47 +69,115 @@ 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); + } + - +
-
+
+

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 +186,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 +282,16 @@

Unlock User

- +

Server Time

-
- -
+
- +

Environment Variables

@@ -182,20 +309,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 +342,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 +367,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 +695,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 +749,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 +776,7 @@

Environment Variables

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

Environment Variables

} ClipCascade - Please Log In + - + +
ClipCascade
diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/logout.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/logout.html index 8a215398..d8ff340a 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/logout.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/logout.html @@ -1,6 +1,8 @@ + + Confirm Log Out? @@ -65,8 +67,19 @@ background-color: #003c97; } + - + +

Are you sure you want to log out?

diff --git a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/signup.html b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/signup.html index d3d8aa2e..b4267bc0 100644 --- a/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/signup.html +++ b/ClipCascade_Server/ClipCascade_Backend/src/main/resources/templates/signup.html @@ -1,6 +1,8 @@ + + ClipCascade - Create Account + - + +
- + diff --git a/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml b/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml index b65e244b..c8ef5891 100644 --- a/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml +++ b/ClipCascade_Server/docker-compose/docker-compose-multi-users.yml @@ -18,9 +18,11 @@ services: CC_P2P_ENABLED: true CC_P2P_STUN_URL: stun:stun.l.google.com:19302 CC_SIGNUP_ENABLED: true + CC_CAPTCHA_DIFFICULTY_PERCENT: 20 CC_MAX_USER_ACCOUNTS: -1 CC_ACCOUNT_PURGE_TIMEOUT_SECONDS: 63115200 CC_SESSION_TIMEOUT: 525960m + CC_SESSION_PERSISTENCE_ENABLED: true CC_PORT: 8080 CC_SERVER_LOGGING_LEVEL: INFO CC_MAX_UNIQUE_IP_ATTEMPTS: 15 diff --git a/ClipCascade_Server/docker-compose/docker-compose.yml b/ClipCascade_Server/docker-compose/docker-compose.yml index 0f89867b..c284664b 100644 --- a/ClipCascade_Server/docker-compose/docker-compose.yml +++ b/ClipCascade_Server/docker-compose/docker-compose.yml @@ -68,6 +68,11 @@ services: # # Default: false # - CC_SIGNUP_ENABLED=false + # # ⚙️ Sets CAPTCHA difficulty as a percentage from 0 (easiest) to 100 (hardest). + # # Lower values reduce visual noise and distortion while keeping a basic challenge. + # # Default: 100 + # - CC_CAPTCHA_DIFFICULTY_PERCENT=100 + # # ⚙️ Sets the maximum number of user accounts that can be created on the server. # # Set to -1 for unlimited accounts. # # Default: -1 (unlimited) @@ -86,10 +91,16 @@ services: # - CC_PORT=8080 # # ⚙️ Configure the server session timeout. - # # Note: On restart, the session will be logged out. + # # Note: On restart, sessions are logged out unless persistence below is enabled. # # Default: 525960m (1 year) # - CC_SESSION_TIMEOUT=525960m + # # ⚙️ Keep login sessions across server restarts in the existing H2/PostgreSQL database. + # # Requires persistent database storage (the /database volume above for H2). + # # Disabling and starting the server deletes saved sessions. Use HTTPS and protect backups. + # # Default: false + # - CC_SESSION_PERSISTENCE_ENABLED=false + # # ⚙️ Maximum number of global WebSocket connections allowed (default: -1 for unlimited). # # Supported only in P2P mode. # # Note: This value represents the total maximum connections across all users. @@ -100,6 +111,11 @@ services: # # Note: This value represents the maximum connections allowed per individual user. # - CC_MAX_WS_CONNECTIONS_PER_USER=-1 + # # ⚙️ Maximum P2P signaling messages allowed per WebSocket session per second. + # # Short bursts up to this limit are allowed; excess messages are dropped. + # # Set to 0 or less to disable the limiter. Default: -1 (disabled) + # - CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC=-1 + # # ------------------------------------------------- # # # # Brute Force Attack (BFA) Protection Settings # # # # ------------------------------------------------- # # diff --git a/README.md b/README.md index 77cb78f7..8a54414d 100644 --- a/README.md +++ b/README.md @@ -261,7 +261,7 @@ To install the ClipCascade Windows desktop application, follow these steps: - Open ClipCascade after installation and **log in** to start syncing your clipboard across devices. - When prompted, enter your **server's IP address, port number, or domain name**. - If encryption is enabled, ensure it is **enabled on all devices**. - - In the **Extra Config** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). + - In the **advanced settings** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). 4. **Network Access Prompt (P2P Mode)** - If the server is running in **P2P mode**, you will see a Windows security prompt asking, **"Do you want to allow public and private networks to access this app?"** @@ -274,6 +274,7 @@ To install the ClipCascade Windows desktop application, follow these steps: To build your own desktop executable from source (`ClipCascade_Desktop/src`): ```bash +pip3 install pyinstaller pip3 install -r requirements_win.txt python3 -m PyInstaller ClipCascade_win.spec ``` @@ -323,7 +324,7 @@ To install the ClipCascade macOS desktop application, follow these steps: 6. **Configure the application** - When prompted, enter your **server's IP address, port number, or domain name**. - If encryption is enabled, ensure it is **enabled on all devices**. - - In the **Extra Config** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). + - In the **advanced settings** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). 7. **Network Access Prompt (P2P Mode)** - If the server is running in **P2P mode**, you will see a macOS security prompt asking, **"Allow "ClipCascade" to find devices on local networks?"** @@ -347,8 +348,9 @@ To install the ClipCascade macOS desktop application, follow these steps: To build your own desktop executable from source (`ClipCascade_Desktop/src`): ```bash -pip3 install -r requirements_mac.txt -python3 -m PyInstaller ClipCascade_macos.spec +pip3 install certifi pyinstaller +pip3 install --no-cache-dir -r requirements_mac.txt +python3 -m PyInstaller ClipCascade_macos.spec --clean ``` [➡️ Explore Advanced Details](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#%EF%B8%8F-advanced-details) @@ -365,7 +367,7 @@ To install the ClipCascade mobile application on your Android device, follow the 4. **Open** ClipCascade and log in to begin syncing your clipboard across devices. - When prompted, enter your **server's IP address, port number, or domain name**. - If encryption is enabled, ensure it is **enabled on all devices**. - - In the **Extra Config** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). + - In the **advanced settings** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). #### Android Automatic Clipboard Monitoring Setup: @@ -508,7 +510,7 @@ sudo pacman -S --noconfirm ffmpeg Start ClipCascade by running (use sudo if needed): - When prompted, enter your **server's IP address, port number, or domain name**. - If encryption is enabled, ensure it is **enabled on all devices**. - - In the **Extra Config** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). + - In the **advanced settings** section, you can set a local clipboard size limit. By default, no limit is enforced (note: large file transfers may cause temporary unresponsiveness). ``` python3 main.py ``` @@ -652,6 +654,11 @@ cd /path/to/clipcascade/src/ && sudo python3 main.py server_update +#### Web Dashboard and Administration + +- Web pages follow your system's light/dark theme on each page load; use the theme toggle to switch the current page's appearance. +- The Admin Panel user list and the Advanced page's User Details, Online Users, and BFA tracker tables support searching, sorting, and pagination. Advanced tables also have resizable columns. Online Users shows active WebSocket connections in both P2S and P2P modes. +- Administrators can revoke sessions for an individual user or use **Revoke All Sessions**. Editing or deleting an account, or changing its password, also revokes its sessions and disconnects its active WebSocket connections on this server without a restart. Deleted or renamed usernames remain reserved until the server restarts. #### Environment Variables: @@ -676,7 +683,7 @@ Defines the maximum message size (in MiB) that the server can handle. - Desktop supports larger clipboard sizes across text, images, and files.

Additional Notes:
-- Clients can set their own limits via the "Extra Config" on the login page.
+- Clients can set their own limits via **Show advanced settings** on the login page.
- If CC_P2P_ENABLED is true, this setting is ignored. 1 @@ -788,6 +795,8 @@ Defines the internal port where the ClipCascade server listens for connections. Specifies the duration before user sessions expire, using minute-based formatting ([number]m).

Default: 525960m (~1 year). +

+Sessions are lost on server restart unless CC_SESSION_PERSISTENCE_ENABLED is enabled. 525960m @@ -891,8 +900,8 @@ Specifies the username for the database connection. Defines the password used for encrypting the user database.

Note:
-- (H2) Replace <file password> <user password> with secure values.
-- Once set, you must use the same password whenever you migrate the database. +- (H2) Change the default password to secure values in the format <file password> <user password> before the first server start, especially when CC_SESSION_PERSISTENCE_ENABLED is enabled, since login sessions are stored in the database.
+- Keep the same value across restarts, upgrades, and migrations when reusing the database. Changing this variable alone does not update the existing database's password. QjuGlhE3uwylBBANMkX1 o2MdEoFgbU5XkFvTftky @@ -1051,6 +1060,35 @@ Defines the STOMP broker password for external message handling. -1 + + + CC_SESSION_PERSISTENCE_ENABLED + + Keeps login sessions across server restarts in the existing H2 or PostgreSQL database. Requires persistent database storage (the /database volume for Docker's default H2 setup). +

+ Sessions still follow CC_SESSION_TIMEOUT. Starting the server with persistence disabled deletes previously saved sessions. + + false + + + + + CC_P2P_SIGNALING_RATE_LIMIT_PER_SEC + + Limits P2P signaling messages per WebSocket session per second. Short bursts up to the limit are allowed; excess messages are dropped. Applies only in P2P mode. Values of 0 or less disable the limiter. + + -1 + + + + + CC_CAPTCHA_DIFFICULTY_PERCENT + + Sets CAPTCHA difficulty from 0 (easiest) to 100 (hardest). Lower values reduce visual noise and distortion while retaining a basic challenge. Values outside this range are clamped to it. + + 100 + + @@ -1059,8 +1097,8 @@ Defines the STOMP broker password for external message handling. ### 🖥️📱 Client Apps - Logs (`clipcascade_log.log`) are stored in the installation directory on Windows and Linux, and in `/Library/Application Support/ClipCascade/` on macOS. These logs allow you to review application activity and are automatically reset each time the application is reopened, preventing indefinite growth. - The `DATA` file stores settings and user details, enabling the app to retain this information across both restarts and updates. -- On Linux and macOS, a `ClipCascade.lock` file is created while the program is running. This file ensures that only a single instance of ClipCascade can be opened at a time. -- All apps include a built-in update check feature, conveniently displayed on the homepage or taskbar. This ensures you can quickly check for updates within the app, keeping you up to date with the latest enhancements and security fixes. +- On Linux and macOS, ClipCascade holds an operating-system lock on `ClipCascade.lock` to allow only one instance at a time. The file remains after exit; the lock is released when the process exits, including after a crash. +- Desktop and Android check for updates at startup and once a day while running, retrying failed checks after an hour. Update status remains visible in the tray/menu or Android home screen. Alerts appear once per new version each app session; Android uses a popup while the app is active or a notification in the background. @@ -1073,21 +1111,38 @@ Defines the STOMP broker password for external message handling.
-#### Extra Config/Advanced Settings (Desktop/Mobile): -- **Maximum Clipboard Size Local Limit (in bytes)**: If the app crashes or stops unexpectedly, it may be due to receiving clipboard content exceeding the platform's maximum size limit. You can set a local size limit by specifying a value in bytes (e.g., 512 KiB = 524288 bytes) to test different thresholds suitable for your device. This local limit works alongside the server-specified limit to ensure smoother operation without crashes. For example, on Android (particularly on the Pixel 6a as of 2024), the platform limit(for text) is typically less than 1 MiB. Since the server limit cannot go below 1 MiB, setting the local limit to around 900,000 bytes on the Pixel 6a can help prevent crashes. -- **Store Password Locally (not recommended)**: Enable this option if you frequently encounter session logouts. While the app stores session cookies for an extended period, a server restart may prompt a re-login. If re-entering the password becomes tedious, you can use this option to store your password locally for convenience. +
+ +#### Login and advanced settings (Desktop/Mobile): + +Use **Log in** to connect. Select **Show advanced settings** to expand additional options and **Hide advanced settings** to collapse them. On desktop, hover over settings for explanations. On Android, tap a label with a small **?** to see its explanation, or use **HELP** to open this documentation. + +Connection status notifications appear on the main login screen, outside advanced settings, on both desktop and Android. Desktop enables them by default; Android disables them by default. Encryption is also on the main login screen and is enabled by default on both platforms. + +Device preferences save as you change them, without requiring a successful login: notification settings, local clipboard size limit, image/file sharing, password-storage preference, the desktop download folder, and Android startup/periodic checks. Server address, credentials, encryption settings, and custom CA configuration are saved after successful login. + +- **Notify when an update is available**: Enabled by default on desktop and Android. Disable it to stop update alerts; update checks and the visible update status remain available. +- **Local clipboard size limit (bytes)**: If the app crashes or stops unexpectedly, it may be due to receiving clipboard content exceeding the platform's maximum size limit. You can set a local size limit by specifying a value in bytes (e.g., 512 KiB = 524288 bytes) to test different thresholds suitable for your device. This local limit works alongside the server-specified limit to ensure smoother operation without crashes. For example, on Android (particularly on the Pixel 6a as of 2024), the platform limit(for text) is typically less than 1 MiB. Since the server limit cannot go below 1 MiB, setting the local limit to around 900,000 bytes on the Pixel 6a can help prevent crashes. +- **Store Password Locally (not recommended)**: Enable this option if you frequently encounter session logouts. While the app stores session cookies for an extended period, a server restart may prompt a re-login unless the server enables session persistence. If re-entering the password becomes tedious, you can use this option to store your password locally for convenience. Disabling this option immediately clears the saved password. > Note: This option will only work if encryption is disabled, as encryption requires the raw password to generate a password hash. - **Enable Image Sharing and Enable File Sharing**: Enabling these options allows the app to send images or files. However, the app will continue to receive images and files even if these options are disabled. -- **Enable Notification**: Turn on this option to receive notifications about WebSocket disconnections and reconnections. -- **Enable Encryption (recommended)**: Enabling this option activates end-to-end encryption for clipboard data. This ensures that all clipboard content is encrypted before leaving your device. Refer to the section below on E2E encryption for detailed instructions on how it works and how to configure the `salt` and `hash rounds`. +- **Show connection status notifications** (Desktop): Turn on this option to receive notifications about WebSocket disconnections and reconnections. +- **Enable encryption (recommended)**: Enabling this option activates end-to-end encryption for clipboard data. This ensures that all clipboard content is encrypted before leaving your device. Refer to the section below on E2E encryption for detailed instructions on how it works and how to configure the encryption salt and key derivation iterations. + +- **Custom CA certificate bundle**: Use a PEM/CRT certificate bundle when your server uses a private/internal CA (for example, corporate PKI). Leave it empty to use default public CA/OS trust. The bundle affects HTTPS/WSS verification only; HTTP/WS connections are still allowed, and TLS certificate and hostname verification remain enabled. + + **Desktop:** Enter the path to a PEM file containing your root CA or certificate chain, or use **Browse…** to select it. The picker offers PEM/CRT filters and an **All files** option; the bundle must contain PEM-encoded certificates. Cancelling keeps the current path unchanged. Use the **trash button** to remove the path and log in to use default certificate verification. + + **Android:** Tap **Import** to select a certificate bundle (up to 1 MiB). The app saves a copy after successful login and restores it for background connections. Imported certificates are trusted alongside system CAs only for the configured server hostname; other hostnames use system trust. Tap **Replace** to import another bundle, or **Remove** and log in to return to system trust only. #### Desktop (Specific): - - **Default File Download Location**: When this path is set, the app will save files directly to the specified location without prompting the user each time the "Download Files" button is clicked. - - **SSL CA bundle**: Path to a PEM file containing your root CA (or full chain) used for HTTPS/WSS verification. Leave it empty to use default public CA/OS trust. Use this field when your ClipCascade server certificate is signed by a private/internal CA (for example, corporate PKI). + - **Default download folder**: Type an existing folder path or use **Browse…** to select one. When this path is set, the app will save files directly to the specified location without prompting the user each time the "Download Files" button is clicked. Cancelling the picker keeps the current path unchanged. Use the **trash button** to choose a folder for each download. + + The **Browse…** buttons use Tkinter dialogs on Windows, macOS, and Linux desktop sessions. Manual path entry remains available. The Linux CLI continues to use typed paths and does not require a graphical picker. #### Android (Specific): - - **Run on System Startup**: Enable this option to allow the app to automatically start on system reboot. By default, this option is disabled. If you are using the [ADB](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) workaround, keep this option disabled to avoid issues with the READ_LOGS permission [popup](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) being dismissed, which prevents clipboard monitoring in the background. - - **Enable WebSocket Status Notification**: Receive alerts when the WebSocket connection is lost or restored, ensuring you're informed about any connection disruptions. + - **Start on device restart (disable if the READ_LOGS permission is granted via ADB)**: Enable this option to allow the app to automatically start on system reboot. By default, this option is disabled. If you are using the [ADB](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) workaround, keep this option disabled to avoid issues with the READ_LOGS permission [popup](https://github.com/Sathvik-Rao/ClipCascade?tab=readme-ov-file#adb-commands) being dismissed, which prevents clipboard monitoring in the background. + - **Show connection status notifications**: Receive alerts when the WebSocket connection is lost or restored, ensuring you're informed about any connection disruptions. periodic_check_notification @@ -1103,13 +1158,13 @@ When encryption is enabled, clipboard data is encrypted directly on the client d Enabling the encryption option is all that's needed to activate encryption by default. However, advanced users have the option to further customize the encryption process by adjusting the following parameters: -- **Salt**: An optional string used as an additional input in the hashing process. For example, you could use a unique string like `"myCustomSalt123"` to enhance encryption security. +- **Encryption salt**: An optional string used as an additional input in the hashing process. For example, you could use a unique string like `"myCustomSalt123"` to enhance encryption security. -- **Hash Rounds**: An integer that specifies the number of hashing iterations. Increasing the hash rounds strengthens encryption by making it more computationally demanding. You can increase the default value of 664,937 to a higher number, such as 1,000,000, to boost security. +- **Key derivation iterations**: An integer that specifies the number of hashing iterations. Increasing the number of iterations strengthens encryption by making it more computationally demanding. You can increase the default value of 664,937 to a higher number, such as 1,000,000, to boost security. -It is crucial to ensure the same **salt** and **hash rounds** are used across all client devices to maintain compatibility. +It is crucial to ensure the same **encryption salt** and **key derivation iterations** are used across all client devices to maintain compatibility. -You can adjust these settings in the **Extra Config** section on the login page for users who require enhanced encryption options. +You can adjust these settings in the **advanced settings** section on the login page for users who require enhanced encryption options. e2e_p2s @@ -1210,6 +1265,13 @@ You can adjust these settings in the **Extra Config** section on the login page +## Server API Documentation + +To build an alternative client, see the [Server API and client implementation guide](ClipCascade_Server/API.md). +It covers authentication and CSRF, HTTP endpoints, admin operations, P2S/STOMP and +P2P/WebRTC protocols, clipboard encryption and payload formats, fragmentation, +reconnection, and working examples with an interoperability checklist. + ## ⇄ Reverse Proxy Setup Below is a screenshot demonstrating how to configure a reverse proxy using **Cloudflare Tunnels**. Similar configurations can be applied with other providers as well.