diff --git a/.cursor/rules/general.mdc b/.cursor/rules/general.mdc index 9c92b16..4d5ccf7 100644 --- a/.cursor/rules/general.mdc +++ b/.cursor/rules/general.mdc @@ -55,4 +55,7 @@ When working with this project, follow these rules: ## Styling - Use SCSS modules - Use nested styles -- Put SCSS into one folder per page \ No newline at end of file +- Put SCSS into one folder per page + +## Animations with Framer Motion +- Don't use variants if they are used only once \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..2688cab --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +# Exclude data directory to prevent local database from being copied into production images +backend/data/ + +# Exclude logs +backend/logs/ + +# Exclude development files +node_modules/ +.git/ +.gitignore +README.md +*.log diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b700cff..15afeb4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -3,17 +3,17 @@ name: Deploy to server on: # Runs on pushes targeting the default branch - push: - branches: ["main"] - paths: - - "backend/**" - - "frontend/**" - - "deployment/**" - - "**/package.json" - - ".nvmrc" - - ".github/workflows/deploy.yml" - - "!frontend/electron/**" - - "!**.d.ts" + # push: + # branches: ["main"] + # paths: + # - "backend/**" + # - "frontend/**" + # - "deployment/**" + # - "**/package.json" + # - ".nvmrc" + # - ".github/workflows/deploy.yml" + # - "!frontend/electron/**" + # - "!**.d.ts" workflow_dispatch: # Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued. @@ -27,7 +27,7 @@ permissions: jobs: deploy: - runs-on: self-hosted + runs-on: raspberry-pi env: HOME: "/root" environment: @@ -60,4 +60,4 @@ jobs: if ! systemctl restart fromchat && sleep 10 && systemctl status fromchat; then journalctl --no-pager -xeu fromchat exit 1 - fi \ No newline at end of file + fi diff --git a/.gitignore b/.gitignore index 4510a4d..6f7251c 100644 --- a/.gitignore +++ b/.gitignore @@ -112,6 +112,7 @@ web_modules/ # dotenv environment variable files .env +.env.prod .env.development.local .env.test.local .env.production.local @@ -574,4 +575,6 @@ backend/alembic/** !backend/alembic/env.py !backend/alembic/script.py.mako !frontend/src/css/lib -**/*.module.scss.d.ts \ No newline at end of file +**/*.module.scss.d.ts + +.cursor/plans \ No newline at end of file diff --git a/.husky/post-push b/.husky/post-push new file mode 100755 index 0000000..7a4e6af --- /dev/null +++ b/.husky/post-push @@ -0,0 +1,116 @@ +#!/bin/sh +# Post-push hook: opens deploy command in system's native terminal +# Cross-platform support: macOS, Linux, Windows, WSL + +# Get the project root directory +PROJECT_ROOT="$(git rev-parse --show-toplevel)" +cd "$PROJECT_ROOT" || exit 1 + +# Command to run in terminal (deploy.sh will load .env from project root) +COMMAND="npm run -s deploy" + +# Detect OS and open appropriate terminal +detect_and_open_terminal() { + # Detect WSL + if [ -n "${WSL_DISTRO_NAME:-}" ] || [ -f /proc/version ] && grep -qi microsoft /proc/version 2>/dev/null; then + # WSL detected - try to open Windows Terminal, fallback to Linux terminals + if command -v wt.exe >/dev/null 2>&1; then + # Windows Terminal (preferred for WSL) + ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g") + ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g") + wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v wsl.exe >/dev/null 2>&1; then + # Fallback: use wsl.exe to open cmd + WINDOWS_PATH=$(wslpath -w "$PROJECT_ROOT" 2>/dev/null || echo "$PROJECT_ROOT") + cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\"" + else + # Fallback to Linux terminal + open_linux_terminal + fi + # macOS + elif [ "$(uname)" = "Darwin" ]; then + # macOS - use .command file with open command + # Clean up old script files and create a new one + rm -f /tmp/post-push-deploy-*.command 2>/dev/null + SCRIPT_FILE=$(mktemp /tmp/post-push-deploy-XXXXXX.command 2>/dev/null) + if [ -z "$SCRIPT_FILE" ] || [ ! -f "$SCRIPT_FILE" ]; then + # Fallback if mktemp fails + SCRIPT_FILE="/tmp/post-push-deploy-$$.command" + fi + { + echo "#!/bin/bash" + echo "clear" + echo "cd '$PROJECT_ROOT'" + echo "export PS1=''" + echo "set +x" + # Export DEPLOYMENT_SERVER if it was set in the hook environment + if [ -n "$DEPLOYMENT_SERVER_VALUE" ]; then + echo "export DEPLOYMENT_SERVER='$DEPLOYMENT_SERVER_VALUE'" + fi + echo "$COMMAND" + echo "echo ''" + echo "echo 'Press Enter to close...'" + echo "read -r" + echo "osascript -e 'tell application \"Terminal\" to close front window' &" + } > "$SCRIPT_FILE" + chmod +x "$SCRIPT_FILE" + # Use open command to launch .command file - opens only one Terminal window + open "$SCRIPT_FILE" + # Windows (Git Bash or similar) + elif [ -n "${MSYSTEM:-}" ] || [ -n "${MINGW64:-}" ] || [ -n "${MINGW32:-}" ]; then + # Git Bash on Windows + if command -v wt.exe >/dev/null 2>&1; then + # Windows Terminal + ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g") + ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g") + wt.exe bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v cmd.exe >/dev/null 2>&1; then + # Command Prompt - convert path to Windows format + WINDOWS_PATH=$(echo "$PROJECT_ROOT" | sed 's|^/\([a-z]\)|\1:|' | sed 's|/|\\|g' | sed 's|\\|\\\\|g') + cmd.exe /c "start cmd /k \"cd /d $WINDOWS_PATH && $COMMAND\"" + else + # Fallback + ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g") + start "Deploy" bash -c "cd '$ESCAPED_PATH' && $COMMAND; exec bash" + fi + # Linux + else + open_linux_terminal + fi +} + +open_linux_terminal() { + # Escape path for use in shell commands + ESCAPED_PATH=$(echo "$PROJECT_ROOT" | sed "s/'/'\"'\"'/g") + + ESCAPED_CMD=$(echo "$COMMAND" | sed "s/'/'\"'\"'/g") + # Try different Linux terminal emulators + if command -v gnome-terminal >/dev/null 2>&1; then + gnome-terminal -- bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v x-terminal-emulator >/dev/null 2>&1; then + x-terminal-emulator -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v konsole >/dev/null 2>&1; then + konsole -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v xterm >/dev/null 2>&1; then + xterm -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v alacritty >/dev/null 2>&1; then + alacritty -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v kitty >/dev/null 2>&1; then + kitty bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + elif command -v tilix >/dev/null 2>&1; then + tilix -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + else + # Last resort: try to find any terminal + TERMINAL=$(command -v x-terminal-emulator gnome-terminal konsole xterm alacritty kitty tilix 2>/dev/null | head -1) + if [ -n "$TERMINAL" ]; then + "$TERMINAL" -e bash -c "cd '$ESCAPED_PATH' && set +x && echo 'Post-push: Running deploy...' && $ESCAPED_CMD && echo '' && echo 'Deploy completed. Press Enter to close...' && read -r && exit" + else + echo "Could not find a terminal emulator. Please run manually: cd '$PROJECT_ROOT' && $COMMAND" + fi + fi +} + +# Run in background so git push doesn't wait +# Add a small delay to ensure git push completes first +(sleep 0.5 && detect_and_open_terminal) & + diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c122bb..fbaf1b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,10 @@ "files.exclude": { "**/__pycache__": true, "**/package-lock.json": true, - "**/*.module.scss.d.ts": true - } + "**/*.module.scss.d.ts": true, + "**/.husky/_": true, + "**/.venv": true, + "**/node_modules": true + }, + "python.terminal.activateEnvironment": false } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9dd345a..ae21fc9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -33,7 +33,7 @@ "panel": "shared" }, "group": { - "kind": "build", + "kind": "build" }, "isBackground": true }, @@ -87,6 +87,18 @@ "focus": false, "panel": "shared" } + }, + { + "label": "Deploy", + "type": "shell", + "command": "npm run deploy", + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "dedicated", + "clear": true + } } ] } \ No newline at end of file diff --git a/README.md b/README.md index 4631c37..45955fa 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ FromChat - полностью открытый мессенджер. -Демо версию можно попробовать на [сайте](http://95.165.0.162:8301). +Его можно попробовать на [сайте](http://fromchat.ru). ## Содержание: - [Основные моменты](#highlights) diff --git a/backend/admin_cli.py b/backend/admin_cli.py new file mode 100644 index 0000000..ebfab0e --- /dev/null +++ b/backend/admin_cli.py @@ -0,0 +1,417 @@ +from __future__ import annotations + +import argparse +import base64 +import hashlib +import hmac +import os +import shlex +import sys +from getpass import getpass +from typing import Iterable, List, Optional, Tuple +import readline +import httpx +from rich.console import Console +from rich.panel import Panel +from rich.table import Table + + +class CLIError(Exception): + """Generic CLI error with a human-readable message.""" + + +def _hkdf_extract(salt: bytes, ikm: bytes) -> bytes: + return hmac.new(salt, ikm, hashlib.sha256).digest() + + +def _hkdf_expand(prk: bytes, info: bytes, length: int) -> bytes: + blocks: list[bytes] = [] + previous = b"" + counter = 1 + while len(b"".join(blocks)) < length: + previous = hmac.new(prk, previous + info + bytes([counter]), hashlib.sha256).digest() + blocks.append(previous) + counter += 1 + return b"".join(blocks)[:length] + + +def derive_auth_secret(username: str, password: str) -> str: + salt = f"fromchat.user:{username}".encode("utf-8") + prk = _hkdf_extract(salt, password.encode("utf-8")) + okm = _hkdf_expand(prk, b"auth-secret", 32) + return base64.b64encode(okm).decode("utf-8") + + +def _read_single_key() -> str: + try: # Windows + import msvcrt # type: ignore + + ch = msvcrt.getch() + return ch.decode("utf-8", errors="ignore").lower() + except ImportError: + import termios + import tty + + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) + try: + tty.setraw(fd) + ch = sys.stdin.read(1) + finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + return ch.lower() + + +class AdminCLI: + def __init__(self, api_url: str) -> None: + self.console = Console() + self.api_url = api_url.rstrip("/") + self.client = httpx.Client(base_url=self.api_url, timeout=30.0) + self.username: Optional[str] = None + self.token: Optional[str] = None + + # --------------------------- HTTP helpers --------------------------- # + def _auth_headers(self) -> dict: + headers: dict = {} + if self.token: + headers["Authorization"] = f"Bearer {self.token}" + return headers + + def _request(self, method: str, path: str, *, auth: bool = True, **kwargs) -> httpx.Response: + rel_path = path.lstrip("/") + headers = kwargs.pop("headers", {}) + if auth: + headers.update(self._auth_headers()) + response = self.client.request(method, rel_path, headers=headers, **kwargs) + if response.status_code >= 400: + detail = "" + try: + payload = response.json() + if isinstance(payload, dict): + detail = payload.get("detail") or payload.get("message") or "" + except Exception: + detail = response.text + message = f"{response.status_code} {response.reason_phrase}" + if detail: + message = f"{message}: {detail}" + raise CLIError(message.strip()) + return response + + # --------------------------- CLI primitives ------------------------- # + def _require_auth(self) -> None: + if not self.token: + raise CLIError("You must login before running this command.") + + def _resolve_user(self, identifier: str) -> dict: + self._require_auth() + if identifier.isdigit(): + response = self._request("GET", f"user/id/{identifier}") + else: + response = self._request("GET", f"user/{identifier.replace('@', '')}") + return response.json() + + def _confirm(self, prompt: str) -> bool: + self.console.print(f"[bold yellow]{prompt}[/] [green](y)[/] / [red](n)[/]: ", end="") + choice = _read_single_key() + self.console.print("") # move to next line + return choice == "y" + + def _render_user(self, user: dict) -> None: + table = Table(show_header=False) + table.add_row("ID", str(user.get("id"))) + table.add_row("Username", user.get("username", "")) + table.add_row("Display name", user.get("display_name", "")) + table.add_row("Verified", "✅" if user.get("verified") else "❌") + if user.get("suspended"): + table.add_row("Suspended", f"🚫 ({user.get('suspension_reason') or 'no reason'})") + else: + table.add_row("Suspended", "✅ Active") + self.console.print(table) + + # --------------------------- Commands ------------------------------- # + def cmd_login(self, args: List[str]) -> None: + if args: + username = args[0] + else: + username = self.console.input("[bold cyan]Username[/]: ").strip() + if not username: + raise CLIError("Username is required.") + + password = getpass("Password: ") + derived_password = derive_auth_secret(username, password) + payload = {"username": username, "password": derived_password} + response = self._request("POST", "login", json=payload, auth=False) + body = response.json() + token = body.get("token") + if not token: + raise CLIError("Authentication succeeded but token was not returned.") + self.token = token + self.username = username + self.console.print("[bold green]Login successful.[/]") + + def cmd_suspend(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: suspend ") + identifier = args[0] + user = self._resolve_user(identifier) + self.console.print(Panel.fit("[bold red]Suspend user[/]", style="red")) + self._render_user(user) + reason = self.console.input("[bold yellow]Reason (press Enter to leave empty)[/]: ").strip() + if not self._confirm(f"Confirm suspension of {user.get('username')}?"): + self.console.print("[yellow]Suspension cancelled.[/]") + return + payload = {"reason": reason} + self._request("POST", f"user/{user['id']}/suspend", json=payload) + log_reason = reason or "no reason provided" + self.console.print(f"[bold red]User {user['username']} suspended ({log_reason}).[/]") + + def cmd_unsuspend(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unsuspend ") + identifier = args[0] + user = self._resolve_user(identifier) + self.console.print(Panel.fit("[bold green]Unsuspend user[/]", style="green")) + self._render_user(user) + if not self._confirm(f"Unsuspend {user.get('username')}?"): + self.console.print("[yellow]Unsuspension cancelled.[/]") + return + self._request("POST", f"user/{user['id']}/unsuspend") + self.console.print(f"[bold green]User {user['username']} unsuspended.[/]") + + def cmd_block_word(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: block-word [additional words...]") + self._require_auth() + words = args + response = self._request("POST", "moderation/blocklist", json={"words": words}) + data = response.json() + added = data.get("added", []) + current = data.get("words", []) + if added: + self.console.print(f"[bold green]Added {len(added)} entr{'y' if len(added)==1 else 'ies'} to blocklist.[/]") + else: + self.console.print("[yellow]No new words added.[/]") + self.console.print(f"Blocklist size: {len(current)}") + + def cmd_list_users(self) -> None: + self._require_auth() + payload = self._request("GET", "user/list").json() + users = payload.get("users", []) + table = Table(title="Users", show_lines=False) + table.add_column("ID") + table.add_column("Username") + table.add_column("Display name") + table.add_column("Suspended") + for user in users: + table.add_row( + str(user.get("id")), + user.get("username", ""), + user.get("display_name", ""), + "🚫" if user.get("suspended") else "✅", + ) + self.console.print(table) + + def cmd_user(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: user ") + user = self._resolve_user(args[0]) + self._render_user(user) + + def cmd_delete(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: delete ") + user = self._resolve_user(args[0]) + self.console.print(Panel.fit("[bold red]Delete user[/]", style="red")) + self._render_user(user) + if not self._confirm(f"Permanently delete {user.get('username')}?"): + self.console.print("[yellow]Deletion cancelled.[/]") + return + self._request("POST", f"user/{user['id']}/delete") + self.console.print(f"[bold red]User {user['username']} deleted.[/]") + + def cmd_unblock_word(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unblock-word [additional words...]") + self._require_auth() + response = self._request("DELETE", "moderation/blocklist", json={"words": args}) + data = response.json() + removed = data.get("removed", []) + current = data.get("words", []) + if removed: + self.console.print(f"[bold green]Removed {len(removed)} entr{'y' if len(removed)==1 else 'ies'} from blocklist.[/]") + else: + self.console.print("[yellow]No matching words removed.[/]") + self.console.print(f"Blocklist size: {len(current)}") + + def cmd_verify(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: verify ") + user = self._resolve_user(args[0]) + if user.get("verified"): + self.console.print(f"[yellow]{user['username']} is already verified.[/]") + return + self._request("POST", f"user/{user['id']}/verify") + self.console.print(f"[bold green]{user['username']} marked as verified.[/]") + + def cmd_unverify(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unverify ") + user = self._resolve_user(args[0]) + if not user.get("verified"): + self.console.print(f"[yellow]{user['username']} is already unverified.[/]") + return + self._request("POST", f"user/{user['id']}/verify") + self.console.print(f"[bold green]{user['username']} is now unverified.[/]") + + def cmd_list_blocklist(self) -> None: + self._require_auth() + response = self._request("GET", "moderation/blocklist") + words = response.json().get("words", []) + if not words: + self.console.print("[cyan]Blocklist is empty.[/]") + return + table = Table(title="Blocked Words", show_lines=True) + table.add_column("Word / Phrase") + for entry in words: + table.add_row(entry) + self.console.print(table) + + def cmd_unblock_ip(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unblock-ip ") + self._require_auth() + ip = args[0].strip() + if not ip: + raise CLIError("IP address cannot be empty") + response = self._request("POST", "moderation/unblock-ip", json={"ip": ip}) + data = response.json() + message = data.get("message", "IP unblocked") + self.console.print(f"[bold green]{message}[/]") + + def cmd_clear_all_rate_limits(self) -> None: + """Clear all rate limit entries. Use with caution.""" + self._require_auth() + if not self._confirm("Clear ALL rate limit entries? This affects all IPs."): + self.console.print("[yellow]Operation cancelled.[/]") + return + response = self._request("POST", "moderation/clear-all-rate-limits") + data = response.json() + message = data.get("message", "Rate limits cleared") + self.console.print(f"[bold green]{message}[/]") + + def cmd_help(self) -> None: + cmds = { + "login [username]": "Authenticate as owner/admin.", + "suspend ": "Suspend account (alias: ban).", + "unsuspend ": "Unsuspend account (alias: unban).", + "delete ": "Permanently delete the user account.", + "verify ": "Mark user as verified.", + "unverify ": "Remove verification flag.", + "block-word ": "Add words/phrases to chat filter.", + "unblock-word ": "Remove words/phrases from filter.", + "blocklist": "Show current blocklist.", + "unblock-ip ": "Unblock an IP address from rate limiting.", + "clear-all-rate-limits": "Clear all rate limit entries (use with caution).", + "list": "List all users.", + "user ": "Show detailed user information.", + "whoami": "Display current session context.", + "help": "Show this help panel.", + "exit": "Quit the CLI.", + } + table = Table(title="Available Commands") + table.add_column("Command", style="cyan") + table.add_column("Description", style="white") + for cmd, desc in cmds.items(): + table.add_row(cmd, desc) + self.console.print(table) + + def cmd_whoami(self) -> None: + if not self.token: + self.console.print("[yellow]Not authenticated.[/]") + return + self.console.print(f"[green]Logged in as[/] [bold]{self.username}[/] ({self.api_url})") + + # --------------------------- Main loop ------------------------------ # + def run(self) -> None: + self.console.print(Panel.fit("[bold magenta]FromChat Admin CLI[/]", style="magenta")) + while True: + prompt_identity = self.username or "guest" + try: + prompt_str = f"\033[36m{prompt_identity}\033[0m \033[1m>\033[0m " + raw = input(prompt_str).strip() + except (KeyboardInterrupt, EOFError): + self.console.print("\n[red]Exiting...[/]") + break + + if not raw: + continue + + try: + parts = shlex.split(raw) + except ValueError as exc: + self.console.print(f"[red]Parse error:[/] {exc}") + continue + + command = parts[0].lstrip("/").lower() + args = parts[1:] + + if command in {"exit", "quit"}: + self.console.print("[red]Goodbye.[/]") + break + + try: + if command == "login": + self.cmd_login(args) + elif command in {"suspend", "ban"}: + self.cmd_suspend(args) + elif command in {"unsuspend", "unban"}: + self.cmd_unsuspend(args) + elif command == "block-word": + self.cmd_block_word(args) + elif command == "unblock-word": + self.cmd_unblock_word(args) + elif command == "blocklist": + self.cmd_list_blocklist() + elif command == "unblock-ip": + self.cmd_unblock_ip(args) + elif command == "clear-all-rate-limits": + self.cmd_clear_all_rate_limits() + elif command == "verify": + self.cmd_verify(args) + elif command == "unverify": + self.cmd_unverify(args) + elif command in {"delete", "remove"}: + self.cmd_delete(args) + elif command == "list": + self.cmd_list_users() + elif command == "user": + self.cmd_user(args) + elif command == "help": + self.cmd_help() + elif command == "whoami": + self.cmd_whoami() + else: + self.console.print("[yellow]Unknown command. Type /help for a list of commands.[/]") + except CLIError as err: + self.console.print(f"[red]Error:[/] {err}") + except httpx.RequestError as err: + self.console.print(f"[red]Network error:[/] {err}") + + self.client.close() + + +def main(argv: Optional[Iterable[str]] = None) -> None: + parser = argparse.ArgumentParser(description="FromChat Emergency Admin CLI") + parser.add_argument( + "--api-url", + default=os.getenv("FC_ADMIN_API_URL", "http://127.0.0.1:8300"), + help="Base API URL for the FromChat backend (default: %(default)s).", + ) + args = parser.parse_args(list(argv) if argv is not None else None) + cli = AdminCLI(args.api_url) + cli.run() + + +if __name__ == "__main__": + main() + diff --git a/backend/app.py b/backend/app.py index 23c30c2..12ce81e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,22 +1,29 @@ -from fastapi import FastAPI +import asyncio +import time +from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from contextlib import asynccontextmanager import subprocess import sys import os -from constants import DATABASE_URL -from routes import account, messaging, profile, push, webrtc, devices +from routes import account, messaging, profile, push, webrtc, devices, moderation import logging from models import User from constants import OWNER_USERNAME -from sqlalchemy import create_engine -from sqlalchemy.orm import sessionmaker +from utils import get_client_ip + +from db import POOL_CONFIG, SessionLocal +from logging_config import access_logger # noqa: F401 - ensure loggers configured +from security.audit import log_access +from security.rate_limit import limiter +from slowapi.middleware import SlowAPIMiddleware logger = logging.getLogger("uvicorn.error") + @asynccontextmanager async def lifespan(app: FastAPI): - # Startup - run migration in separate process to avoid logging interference + # Startup - run migration in subprocess to avoid logging interference try: logger.info("Starting database migration check...") # Run migration in a separate process @@ -31,14 +38,10 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to run database migrations: {e}") raise - + try: - engine = create_engine(DATABASE_URL) - SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) - with SessionLocal() as db: - # Find the owner user - owner = db.query(User).filter(User.username == OWNER_USERNAME).first() + owner = db.query(User).filter(User.id == 1).first() if owner and not owner.verified: owner.verified = True db.commit() @@ -47,10 +50,18 @@ async def lifespan(app: FastAPI): logger.info(f"Owner user '{OWNER_USERNAME}' is already verified") else: logger.warning(f"Owner user '{OWNER_USERNAME}' not found") - except Exception as e: logger.error(f"Failed to ensure owner verification: {e}") + logger.info( + "SQLAlchemy pool configured (size=%s, max_overflow=%s, timeout=%ss, recycle=%ss, pre_ping=%s)", + POOL_CONFIG["pool_size"], + POOL_CONFIG["max_overflow"], + POOL_CONFIG["pool_timeout"], + POOL_CONFIG["pool_recycle"], + POOL_CONFIG["pool_pre_ping"], + ) + # Start the messaging cleanup task try: from routes.messaging import messagingManager @@ -59,13 +70,87 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to start messaging cleanup task: {e}") + # Reset all rate limits on startup to ensure clean state + # This prevents rate limits from persisting across restarts + try: + from security.rate_limit import reset_all_rate_limits + cleared = reset_all_rate_limits() + if cleared > 0: + logger.info(f"Cleared {cleared} rate limit entries on startup") + except Exception as e: + logger.warning(f"Failed to reset rate limits on startup: {e}") + + # Start the rate limit cleanup task + try: + from security.rate_limit import start_rate_limit_cleanup_task + cleanup_task = asyncio.create_task(start_rate_limit_cleanup_task()) + logger.info("Rate limit cleanup task started") + except Exception as e: + logger.error(f"Failed to start rate limit cleanup task: {e}") + cleanup_task = None + yield - # Shutdown (if needed in the future) + # Shutdown - cancel cleanup task if it exists + if cleanup_task: + cleanup_task.cancel() + try: + await cleanup_task + except asyncio.CancelledError: + pass # Инициализация FastAPI app = FastAPI(title="FromChat", lifespan=lifespan) +# Add rate limiting middleware +app.state.limiter = limiter +app.add_middleware(SlowAPIMiddleware) + + +@app.middleware("http") +async def access_logging_middleware(request: Request, call_next): + # Log incoming request and Authorization header presence for debugging auth issues + try: + auth_header = request.headers.get("authorization") + if auth_header: + short = auth_header[:20] + "..." if len(auth_header) > 20 else auth_header + logger.info("Incoming request %s %s Authorization=%s", request.method, request.url.path, short) + else: + logger.info("Incoming request %s %s Authorization=NONE", request.method, request.url.path) + except Exception: + pass + start = time.perf_counter() + try: + response = await call_next(request) + except Exception as exc: + duration = time.perf_counter() - start + user = getattr(getattr(request, "state", None), "current_user", None) + log_access( + "http_error", + method=request.method, + path=request.url.path, + status="error", + user=getattr(user, "username", None), + ip=get_client_ip(request), + duration=f"{duration:.3f}s", + error=str(exc), + ) + raise + else: + duration = time.perf_counter() - start + user = getattr(getattr(request, "state", None), "current_user", None) + log_access( + "http_request", + method=request.method, + path=request.url.path, + status=response.status_code, + user=getattr(user, "username", None), + ip=get_client_ip(request), + duration=f"{duration:.3f}s", + ) + return response + + # CORS app.add_middleware( CORSMiddleware, @@ -89,4 +174,5 @@ app.include_router(messaging.router) app.include_router(profile.router) app.include_router(push.router, prefix="/push") app.include_router(webrtc.router, prefix="/webrtc") -app.include_router(devices.router, prefix="/devices") \ No newline at end of file +app.include_router(devices.router, prefix="/devices") +app.include_router(moderation.router) \ No newline at end of file diff --git a/backend/constants.py b/backend/constants.py index e1086c9..bfddb72 100644 --- a/backend/constants.py +++ b/backend/constants.py @@ -1,9 +1,11 @@ import os - DATABASE_URL = "sqlite:///./data/database.db" JWT_ALGORITHM = "HS256" -ACCESS_TOKEN_EXPIRE_HOURS = 24 +# Token inactivity expiration - token expires if not used for this duration +TOKEN_INACTIVITY_EXPIRE_HOURS = 30 * 24 # 30 days of inactivity +# Maximum token lifetime (safety net) - tokens expire after this regardless of usage +MAX_TOKEN_LIFETIME_HOURS = 365 * 24 # 1 year maximum OWNER_USERNAME = "denis0001-dev" JWT_SECRET_KEY = os.getenv("JWT_SECRET") diff --git a/backend/db.py b/backend/db.py index 1708283..a700933 100644 --- a/backend/db.py +++ b/backend/db.py @@ -6,5 +6,35 @@ from constants import DATABASE_URL # Ensure data directory exists os.makedirs("data", exist_ok=True) -engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False}) +POOL_SIZE = int(os.getenv("DB_POOL_SIZE", "20")) +MAX_OVERFLOW = int(os.getenv("DB_MAX_OVERFLOW", "40")) +POOL_RECYCLE = int(os.getenv("DB_POOL_RECYCLE", "1800")) +POOL_TIMEOUT = int(os.getenv("DB_POOL_TIMEOUT", "30")) + +POOL_CONFIG = { + "pool_size": POOL_SIZE, + "max_overflow": MAX_OVERFLOW, + "pool_recycle": POOL_RECYCLE, + "pool_timeout": POOL_TIMEOUT, + "pool_pre_ping": True, +} + +engine_kwargs = { + "pool_size": POOL_SIZE, + "max_overflow": MAX_OVERFLOW, + "pool_recycle": POOL_RECYCLE, + "pool_pre_ping": True, + "pool_timeout": POOL_TIMEOUT, +} + +connect_args = {} +if DATABASE_URL.startswith("sqlite"): + connect_args["check_same_thread"] = False + +engine = create_engine( + DATABASE_URL, + connect_args=connect_args, + **engine_kwargs, +) + SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) \ No newline at end of file diff --git a/backend/dependencies.py b/backend/dependencies.py index 3178e80..d4a126e 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,11 +1,14 @@ -from fastapi import Depends, HTTPException, status +from datetime import datetime, timedelta +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session -from utils import * -from models import * +from utils import verify_token +from models import User, DeviceSession from db import SessionLocal +import logging security = HTTPBearer() +logger = logging.getLogger("uvicorn.error") # Зависимость для получения сессии БД def get_db(): @@ -17,12 +20,22 @@ def get_db(): # Зависимость для получения текущего пользователя def get_current_user( + request: Request, credentials: HTTPAuthorizationCredentials = Depends(security), - db: Session = Depends(get_db) + db: Session = Depends(get_db), ) -> User: token = credentials.credentials - payload = verify_token(token) + try: + payload = verify_token(token) + except Exception as e: + logger.warning("get_current_user: token verification error: %s", str(e)) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) if not payload: + logger.info("get_current_user: verify_token returned empty payload") raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token", @@ -30,12 +43,19 @@ def get_current_user( ) user = db.query(User).filter(User.id == payload["user_id"]).first() if not user: + logger.info("get_current_user: user not found for user_id=%s", payload.get("user_id")) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="User not found", headers={"WWW-Authenticate": "Bearer"}, ) + if user.id == 1 and user.suspended: + user.suspended = False + user.suspension_reason = None + db.commit() + db.refresh(user) + # Validate device session from JWT session_id = payload.get("session_id") if not session_id: @@ -52,18 +72,34 @@ def get_current_user( ) if not device_session or device_session.revoked: + logger.info("get_current_user: session missing/revoked for user_id=%s session_id=%s", user.id, session_id) raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="Session revoked or not found", headers={"WWW-Authenticate": "Bearer"}, ) - # Touch last_seen on valid session + # Check if session has been inactive for too long (sliding expiration) + from constants import TOKEN_INACTIVITY_EXPIRE_HOURS + inactivity_threshold = datetime.now() - timedelta(hours=TOKEN_INACTIVITY_EXPIRE_HOURS) + if device_session.last_seen < inactivity_threshold: + # Session expired due to inactivity - revoke it + device_session.revoked = True + db.commit() + logger.info("get_current_user: session expired due to inactivity for user_id=%s session_id=%s", user.id, session_id) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Session expired due to inactivity", + headers={"WWW-Authenticate": "Bearer"}, + ) + + # Touch last_seen on valid session (sliding expiration - extends token life) device_session.last_seen = datetime.now() db.commit() # Check if user is suspended if user.suspended: + logger.info("get_current_user: account suspended for user_id=%s reason=%s", user.id, user.suspension_reason) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account suspended", @@ -72,9 +108,13 @@ def get_current_user( # Check if user is deleted if user.deleted: + logger.info("get_current_user: account deleted for user_id=%s", user.id) raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail="Account deleted", ) + request.state.current_user = user + request.state.session_id = session_id + return user \ No newline at end of file diff --git a/backend/logging_config.py b/backend/logging_config.py new file mode 100644 index 0000000..65922ce --- /dev/null +++ b/backend/logging_config.py @@ -0,0 +1,89 @@ +import logging +import os +from datetime import datetime +from logging.handlers import RotatingFileHandler +from pathlib import Path +from threading import RLock +from typing import Dict + +LOGS_DIR = Path(__file__).resolve().parent / "logs" +LOGS_DIR.mkdir(parents=True, exist_ok=True) + + +class HumanReadableFileHandler(RotatingFileHandler): + def __init__(self, filename: Path, level: int) -> None: + super().__init__(filename, maxBytes=5 * 1024 * 1024, backupCount=5, encoding="utf-8", delay=True) + self.level = level + self._lock = RLock() + self._last_date: str | None = None + self._previous_entry: str | None = None + + def emit(self, record: logging.LogRecord) -> None: + try: + message = record.getMessage().strip() + if not message: + return + + timestamp = datetime.fromtimestamp(record.created) + date_str = timestamp.strftime("%d.%m.%Y") + time_str = timestamp.strftime("%H:%M:%S") + lines = [line.rstrip() for line in message.splitlines() if line.strip()] + + with self._lock: + if self.stream is None: + self.stream = self._open() + + if self._last_date != date_str: + if self._last_date is not None: + self.stream.write("\n") + separator = "-" * 11 + self.stream.write(f"\n\n{separator}\n{date_str}\n{separator}\n\n") + self._last_date = date_str + + entry_lines: list[str] = [] + if lines: + entry_lines.append(f"{time_str} {lines[0]}") + for line in lines[1:]: + if line.startswith("|"): + entry_lines.append(f" {line}") + else: + entry_lines.append(f" ↳ {line}") + else: + entry_lines.append(time_str) + entry_text = "\n".join(entry_lines) + if entry_text == self._previous_entry: + return + self.stream.write(entry_text + "\n") + self._previous_entry = entry_text + self.flush() + except Exception: + self.handleError(record) + + +_HANDLED_FILES: Dict[str, Path] = {} + + +def _configure_logger(name: str, filename: str, level: int = logging.INFO) -> logging.Logger: + logger = logging.getLogger(name) + target_path = LOGS_DIR / filename + + if _HANDLED_FILES.get(name) == target_path: + return logger + + logger.handlers.clear() + + handler = HumanReadableFileHandler(target_path, level) + handler.setLevel(level) + logger.addHandler(handler) + logger.setLevel(level) + logger.propagate = False + + _HANDLED_FILES[name] = target_path + return logger + + +security_logger = _configure_logger("security", "security.log") +public_chat_logger = _configure_logger("public_chat", "public-chat.log") +dm_logger = _configure_logger("dm", "dm.log") +access_logger = _configure_logger("access", "access.log") + diff --git a/backend/migration.py b/backend/migration.py index a085580..c8d67c6 100644 --- a/backend/migration.py +++ b/backend/migration.py @@ -20,18 +20,33 @@ def run_migrations(): Fully automated - handles all scenarios automatically. """ try: + # FIRST: Check if database has any application tables (excluding alembic_version) + engine = create_engine(DATABASE_URL) + with engine.connect() as connection: + from sqlalchemy import inspect + inspector = inspect(connection) + existing_tables = [table for table in inspector.get_table_names() + if not table.startswith('sqlite_') and table != 'alembic_version'] + + # If no application tables exist, create them directly from models + if not existing_tables: + logger.info("No application tables found. Creating all tables directly from models...") + from models import Base + Base.metadata.create_all(bind=engine) + logger.info("All tables created successfully from models.") + # Get the directory where this script is located current_dir = os.path.dirname(os.path.abspath(__file__)) - + # Create Alembic configuration alembic_cfg = Config(os.path.join(current_dir, "alembic.ini")) - + # Disable Alembic's logging configuration to avoid interfering with FastAPI alembic_cfg.set_main_option("configure_logging", "false") - + # Set the database URL in the config alembic_cfg.set_main_option("sqlalchemy.url", DATABASE_URL) - + # Check if any migration files exist versions_dir = os.path.join(current_dir, "alembic", "versions") diff --git a/backend/models.py b/backend/models.py index 8d581ee..9e2e1bd 100644 --- a/backend/models.py +++ b/backend/models.py @@ -113,6 +113,16 @@ class PushSubscription(Base): updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) +class FcmToken(Base): + __tablename__ = "fcm_token" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) + token = Column(Text, nullable=False, unique=True) + created_at = Column(DateTime, default=datetime.now) + updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now) + + class Reaction(Base): __tablename__ = "reaction" @@ -283,5 +293,20 @@ class DMReactionResponse(BaseModel): from_attributes = True +class UpdateLog(Base): + """Stores update sequence numbers and updates for gap detection""" + __tablename__ = "update_log" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("user.id"), nullable=False, index=True) + sequence = Column(Integer, nullable=False, index=True) + updates = Column(Text, nullable=False) # JSON array of updates + timestamp = Column(DateTime, default=datetime.now, index=True) + + __table_args__ = ( + UniqueConstraint("user_id", "sequence", name="uq_user_sequence"), + ) + + # Tables are now created through Alembic migrations # Base.metadata.create_all(bind=engine) \ No newline at end of file diff --git a/backend/push_service.py b/backend/push_service.py index ee48ded..6d53416 100644 --- a/backend/push_service.py +++ b/backend/push_service.py @@ -5,6 +5,11 @@ from typing import List, Optional from sqlalchemy.orm import Session from pywebpush import webpush, WebPushException from models import PushSubscription, User, Message, DMEnvelope +from models import FcmToken +import firebase_admin +from firebase_admin import credentials as firebase_credentials +from firebase_admin import messaging as firebase_messaging +import base64 logger = logging.getLogger("uvicorn.error") @@ -12,6 +17,24 @@ class PushNotificationService: def __init__(self): self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY") self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY") + # Firebase Admin initialization (modern API). Only FIREBASE_CERT env is supported. + self.firebase_initialized = False + try: + firebase_cert = os.getenv("FIREBASE_CERT") + if not firebase_cert: + raise RuntimeError("FIREBASE_CERT env variable is required for Firebase Admin SDK initialization") + + # Support raw JSON or base64-encoded JSON in FIREBASE_CERT + decoded = base64.b64decode(firebase_cert).decode("utf-8") + sa_dict = json.loads(decoded) + + cred = firebase_credentials.Certificate(sa_dict) + firebase_admin.initialize_app(cred) + self.firebase_initialized = True + logger.info("Firebase Admin SDK initialized for push sending (FIREBASE_CERT)") + except Exception as e: + logger.error(f"Failed to initialize Firebase Admin SDK from FIREBASE_CERT: {e}") + raise if (not self.vapid_public_key) or (not self.vapid_private_key): raise ValueError("VAPID public or private key is None") @@ -57,42 +80,61 @@ class PushNotificationService: users = db.query(User).filter(User.id != message.user_id) if exclude_user_id: users = users.filter(User.id != exclude_user_id) - + for user in users: # Check if user has push subscription before trying to send + # Try all FCM tokens first (Android). If none or all fail, fall back to web push subscription. + fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == user.id).all() + payload_data = { + "type": "public_message", + "message_id": message.id, + "sender_id": message.user_id, + "sender_username": message.author.username + } + title = f"{message.author.username}" + body = message.content[:100] + ("..." if len(message.content) > 100 else "") + + if fcm_rows and self.firebase_initialized: + for fcm in fcm_rows: + try: + self._send_fcm_to_token(fcm.token, title, body, payload_data) + except Exception as e: + logger.error(f"Failed to send FCM to user {user.id} token {fcm.token}: {e}") + # Check if this is a permanent failure and clean up the token + self._cleanup_failed_fcm_token(db, fcm, str(e)) + subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first() - if not subscription: - continue - - await self._send_notification_to_user( - db, user.id, - f"New message from {message.author.username}", - message.content[:100] + ("..." if len(message.content) > 100 else ""), - message.author.profile_picture, - { - "type": "public_message", - "message_id": message.id, - "sender_id": message.user_id, - "sender_username": message.author.username - } - ) + if subscription: + await self._send_notification_to_user( + db, user.id, title, body, message.author.profile_picture, payload_data + ) except Exception as e: logger.error(f"Failed to send public message notifications: {e}") async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User): """Send push notification for a new DM""" try: + title = f"{sender.username}" + body = "New direct message" + payload_data = { + "type": "dm", + "dm_id": dm_envelope.id, + "sender_id": sender.id, + "sender_username": sender.username + } + + fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == dm_envelope.recipient_id).all() + if fcm_rows and self.firebase_initialized: + for fcm in fcm_rows: + try: + self._send_fcm_to_token(fcm.token, title, body, payload_data) + except Exception as e: + logger.error(f"Failed to send FCM to user {dm_envelope.recipient_id} token {fcm.token}: {e}") + # Check if this is a permanent failure and clean up the token + self._cleanup_failed_fcm_token(db, fcm, str(e)) + await self._send_notification_to_user( - db, dm_envelope.recipient_id, - f"New message from {sender.username}", - "You have a new direct message", - sender.profile_picture, - { - "type": "dm", - "dm_id": dm_envelope.id, - "sender_id": sender.id, - "sender_username": sender.username - } + db, dm_envelope.recipient_id, title, body, sender.profile_picture, payload_data ) except Exception as e: logger.error(f"Failed to send DM notification: {e}") @@ -107,7 +149,7 @@ class PushNotificationService: payload = { "title": title, "body": body, - "icon": icon or "/logo.png", + "icon": icon or "about:blank", "tag": f"message_{user_id}", "data": data } @@ -136,6 +178,56 @@ class PushNotificationService: except Exception as e: logger.error(f"Failed to send push notification to user {user_id}: {e}") + def _send_fcm_to_token(self, token: str, title: str, body: str, data: dict): + """Send an FCM data-only push to a single device token using Firebase Admin SDK. + Notification display is handled by the app, not FCM.""" + if not self.firebase_initialized: + raise RuntimeError("Firebase Admin SDK not initialized (FIREBASE_CERT required)") + + try: + # Send only data payload - let the app handle notification display + # This prevents FCM from auto-showing notifications + msg = firebase_messaging.Message( + token=token, + data={ + "title": title, + "body": body, + **{k: str(v) for k, v in (data or {}).items()} + }, + android=firebase_messaging.AndroidConfig(priority="high"), + apns=firebase_messaging.APNSConfig(headers={"apns-priority": "10"}) + ) + resp = firebase_messaging.send(msg) + return resp + except Exception as e: + logger.error(f"Firebase Admin send failed for token {token}: {e}") + raise + + def _cleanup_failed_fcm_token(self, db: Session, fcm_token_entry, error_message: str): + """Clean up FCM tokens that have permanent failures""" + try: + # Check for permanent failure indicators in the error message + permanent_errors = [ + "unregistered", "invalidregistration", "notregistered", + "sender_id_mismatch", "invalid_argument" + ] + + error_lower = error_message.lower() + is_permanent = any(permanent_error in error_lower for permanent_error in permanent_errors) + + if is_permanent: + logger.info(f"Removing permanently failed FCM token for user {fcm_token_entry.user_id}: {fcm_token_entry.token}") + db.query(FcmToken).filter(FcmToken.id == fcm_token_entry.id).delete() + db.commit() + else: + logger.debug(f"Temporary FCM failure for token {fcm_token_entry.token}, keeping token: {error_message}") + except Exception as e: + logger.error(f"Failed to cleanup FCM token {fcm_token_entry.token}: {e}") + try: + db.rollback() + except Exception: + pass + async def unsubscribe_user(self, db: Session, user_id: int) -> bool: """Unsubscribe a user from push notifications""" try: diff --git a/backend/requirements.txt b/backend/requirements.txt index 3cf107f..0a78322 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,7 @@ cryptography>=41.0.0 alembic>=1.13.2 better-profanity>=0.7.0 user-agents>=2.2.0 +httpx>=0.27.2 +rich>=13.9.4 +slowapi>=0.1.9 +firebase_admin>=7.1.0 \ No newline at end of file diff --git a/backend/routes/account.py b/backend/routes/account.py index ff8cc26..620c875 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -1,4 +1,6 @@ from datetime import datetime +from collections import defaultdict, deque +import time from fastapi import APIRouter, Depends, HTTPException, status, Request from sqlalchemy.orm import Session from sqlalchemy import inspect, text @@ -8,14 +10,38 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from constants import OWNER_USERNAME from dependencies import get_current_user, get_db -from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from models import LoginRequest, RegisterRequest, ChangePasswordRequest, User, CryptoPublicKey, CryptoBackup, DeviceSession -from utils import create_token, get_password_hash, verify_password +from utils import create_token, get_password_hash, verify_password, get_client_ip from validation import is_valid_password, is_valid_username, is_valid_display_name import os +from security.audit import log_security +from security.profanity import contains_profanity +from security.rate_limit import rate_limit_per_ip router = APIRouter() +_FAILED_ATTEMPT_WINDOW_SECONDS = 300 +_FAILED_ATTEMPT_THRESHOLD = 5 +_failed_login_attempts: dict[str, deque[float]] = defaultdict(deque) + + +def _record_failed_login(identifier: str) -> bool: + now = time.time() + attempts = _failed_login_attempts[identifier] + attempts.append(now) + + while attempts and now - attempts[0] > _FAILED_ATTEMPT_WINDOW_SECONDS: + attempts.popleft() + + return len(attempts) >= _FAILED_ATTEMPT_THRESHOLD + + +def _reset_failed_logins(identifier: str) -> None: + _failed_login_attempts.pop(identifier, None) + +def _is_admin(user: User) -> bool: + return user.id == 1 + def convert_user(user: User) -> dict: return { "id": user.id, @@ -26,11 +52,11 @@ def convert_user(user: User) -> dict: "display_name": user.display_name, "profile_picture": user.profile_picture, "bio": user.bio, - "admin": user.username == OWNER_USERNAME, + "admin": _is_admin(user), "verified": user.verified, "suspended": user.suspended or False, "suspension_reason": user.suspension_reason, - "deleted": user.deleted or False + "deleted": (user.deleted or user.suspended) or False # Treat suspended as deleted } @router.get("/check_auth") @@ -38,23 +64,57 @@ def check_auth(current_user: User = Depends(get_current_user)): return { "authenticated": True, "username": current_user.username, - "admin": current_user.username == OWNER_USERNAME + "admin": _is_admin(current_user) } @router.post("/login") -def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = None): - user = db.query(User).filter(User.username == request.username.strip()).first() +@rate_limit_per_ip("5/minute") +def login(request: Request, login_request: LoginRequest, db: Session = Depends(get_db)): + username = login_request.username.strip() + client_ip = get_client_ip(request) + raw_ua = request.headers.get("user-agent") - if not user or not verify_password(request.password.strip(), user.password_hash): + user = db.query(User).filter(User.username == username).first() + + if not user or not verify_password(login_request.password.strip(), user.password_hash): + log_security( + "login_failed", + severity="warning", + username=username, + ip=client_ip, + reason="invalid_credentials", + ) + identifiers = [f"user:{username}"] + if client_ip: + identifiers.append(f"ip:{client_ip}") + + suspicious = False + for identifier in identifiers: + if _record_failed_login(identifier): + suspicious = True + + if suspicious: + total_failures = { + identifier: len(_failed_login_attempts.get(identifier, [])) + for identifier in identifiers + } + log_security( + "auth_bruteforce_detected", + severity="warning", + username=username, + ip=client_ip, + failures=total_failures, + window_seconds=_FAILED_ATTEMPT_WINDOW_SECONDS, + ) raise HTTPException( status_code=401, detail="Неверное имя пользователя или пароль" ) # Create device session and embed into JWT - raw_ua = http.headers.get("user-agent") if http else None - device_name = http.headers.get("x-device-name") if http else None + raw_ua = request.headers.get("user-agent") + device_name = request.headers.get("x-device-name") ua = parse_ua(raw_ua or "") session_id = uuid.uuid4().hex @@ -82,6 +142,23 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = token = create_token(user.id, user.username, session_id) + identifiers = [f"user:{username}"] + if client_ip: + identifiers.append(f"ip:{client_ip}") + for identifier in identifiers: + _reset_failed_logins(identifier) + + log_security( + "login_success", + username=user.username, + user_id=user.id, + ip=client_ip, + session_id=session_id, + device=device.device_type, + os=device.os_name, + browser=device.browser_name, + ) + return { "status": "success", "message": "Login successful", @@ -91,34 +168,40 @@ def login(request: LoginRequest, db: Session = Depends(get_db), http: Request = @router.post("/register") -def register(request: RegisterRequest, db: Session = Depends(get_db), http: Request = None): - username = request.username.strip() - display_name = request.display_name.strip() - password = request.password.strip() - confirm_password = request.confirm_password.strip() +@rate_limit_per_ip("3/hour") +def register(request: Request, register_request: RegisterRequest, db: Session = Depends(get_db)): + username = register_request.username.strip() + display_name = register_request.display_name.strip() + password = register_request.password.strip() + confirm_password = register_request.confirm_password.strip() + client_ip = get_client_ip(request) + raw_ua = request.headers.get("user-agent") # Determine if owner already exists owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None - # If owner not yet registered, only allow the owner to register - if not owner_exists and username != OWNER_USERNAME: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="Регистрация временно закрыта до регистрации владельца" - ) - # Validate input if not is_valid_username(username): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" ) + if contains_profanity(username): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Имя пользователя содержит запрещённые слова" + ) if not is_valid_display_name(display_name): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" ) + if contains_profanity(display_name): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Отображаемое имя содержит запрещённые слова" + ) if not is_valid_password(password): raise HTTPException( @@ -132,13 +215,6 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ detail="Пароли не совпадают" ) - # After owner exists, disallow registering the reserved owner username via public registration - if owner_exists and username == OWNER_USERNAME: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail="Это имя пользователя зарезервировано" - ) - existing_user = db.query(User).filter(User.username == username).first() if existing_user: raise HTTPException( @@ -165,8 +241,8 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ db.refresh(new_user) # Create initial device session - raw_ua = http.headers.get("user-agent") if http else None - device_name = http.headers.get("x-device-name") if http else None + raw_ua = request.headers.get("user-agent") + device_name = request.headers.get("x-device-name") ua = parse_ua(raw_ua or "") session_id = uuid.uuid4().hex device = DeviceSession( @@ -190,6 +266,24 @@ def register(request: RegisterRequest, db: Session = Depends(get_db), http: Requ token = create_token(new_user.id, new_user.username, session_id) + os_name = ua.os.family or "Unknown OS" + if ua.os.version_string: + os_name = f"{os_name} {ua.os.version_string}" + browser_name = ua.browser.family or "Unknown browser" + if ua.browser.version_string: + browser_name = f"{browser_name} {ua.browser.version_string}" + user_agent_summary = f"{os_name}, {browser_name}" + + log_security( + "registration_success", + username=new_user.username, + display_name=new_user.display_name, + user_id=new_user.id, + ip=client_ip, + user_agent=user_agent_summary, + owner=is_owner, + ) + return { "status": "success", "message": "Регистрация прошла успешно", @@ -208,6 +302,8 @@ def set_public_key(payload: dict, current_user: User = Depends(get_current_user) pk = payload.get("publicKey") if not pk: raise HTTPException(status_code=400, detail="publicKey required") + if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10: + raise HTTPException(status_code=400, detail="Invalid publicKey format") row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() if row: row.public_key_b64 = pk @@ -229,6 +325,8 @@ def set_backup(payload: dict, current_user: User = Depends(get_current_user), db blob = payload.get("blob") if not blob: raise HTTPException(status_code=400, detail="blob required") + if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit + raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB") row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() if row: row.blob_json = blob @@ -246,7 +344,7 @@ def delete_user_as_owner( db: Session = Depends(get_db) ): # Only owner can delete users - if current_user.username != OWNER_USERNAME: + if _is_admin(current_user): raise HTTPException(status_code=403, detail="Only owner can perform this action") user = db.query(User).filter(User.id == user_id).first() @@ -254,7 +352,7 @@ def delete_user_as_owner( raise HTTPException(status_code=404, detail="User not found") # Prevent deleting the owner account via API - if user.username == OWNER_USERNAME: + if _is_admin(user): raise HTTPException(status_code=400, detail="Cannot delete owner account") # Manually delete user's messages to satisfy FK constraints @@ -264,10 +362,20 @@ def delete_user_as_owner( db.delete(user) db.commit() + log_security( + "admin_delete_user", + severity="warning", + actor=current_user.username, + actor_id=current_user.id, + target_username=user.username, + target_id=user.id, + ) + return {"status": "success", "deleted_user_id": user_id} @router.get("/logout") def logout( + http: Request, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), current_user: User = Depends(get_current_user), db: Session = Depends(get_db) @@ -285,6 +393,15 @@ def logout( current_user.last_seen = datetime.now() db.commit() + client_ip = get_client_ip(http) + log_security( + "logout", + username=current_user.username, + user_id=current_user.id, + ip=client_ip, + session_id=payload.get("session_id") if payload else None, + ) + return { "status": "success", "message": "Logged out successfully" @@ -292,22 +409,24 @@ def logout( @router.post("/change-password") +@rate_limit_per_ip("5/hour") def change_password( - request: ChangePasswordRequest, + request: Request, + password_request: ChangePasswordRequest, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): # Verify current derived password against stored hash - if not verify_password(request.currentPasswordDerived.strip(), current_user.password_hash): + if not verify_password(password_request.currentPasswordDerived.strip(), current_user.password_hash): raise HTTPException(status_code=401, detail="Текущий пароль неверный") # Update password hash to hash of new derived password - current_user.password_hash = get_password_hash(request.newPasswordDerived.strip()) + current_user.password_hash = get_password_hash(password_request.newPasswordDerived.strip()) db.commit() # Optionally revoke all other sessions, keeping the current one - if request.logoutAllExceptCurrent: + if password_request.logoutAllExceptCurrent: from utils import verify_token as _verify_token payload = _verify_token(credentials.credentials) if not payload: @@ -319,11 +438,21 @@ def change_password( ).update({DeviceSession.revoked: True}) db.commit() + client_ip = get_client_ip(request) + log_security( + "password_changed", + username=current_user.username, + user_id=current_user.id, + ip=client_ip, + logout_others=bool(password_request.logoutAllExceptCurrent), + ) + return {"status": "success"} @router.get("/users") -def list_users(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +@rate_limit_per_ip("30/minute") # Per-IP limit to prevent abuse +def list_users(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): users = db.query(User).order_by(User.username.asc()).all() return { "users": [ @@ -333,13 +462,15 @@ def list_users(current_user: User = Depends(get_current_user), db: Session = Dep @router.get("/crypto/public-key/of/{user_id}") -def get_public_key_of(user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +@rate_limit_per_ip("100/minute") # Per-IP limit to prevent abuse +def get_public_key_of(request: Request, user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == user_id).first() return {"publicKey": row.public_key_b64 if row else None} @router.get("/users/search") -def search_users(q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse +def search_users(request: Request, q: str, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): if len(q.strip()) < 2: return {"users": []} @@ -425,11 +556,18 @@ async def delete_account( Delete the current user's own account - preserves messages/DMs/reactions/files """ # Prevent admin/owner account self-deletion - if current_user.username == OWNER_USERNAME or current_user.id == 1: + if _is_admin(current_user): raise HTTPException(status_code=400, detail="Cannot delete admin/owner account") await _delete_user_data(current_user, db) + log_security( + "self_delete_account", + severity="warning", + user_id=current_user.id, + username=current_user.username, + ) + return { "status": "success", "message": "Account deleted successfully" diff --git a/backend/routes/devices.py b/backend/routes/devices.py index 550c5f9..7cf41b9 100644 --- a/backend/routes/devices.py +++ b/backend/routes/devices.py @@ -60,6 +60,9 @@ def revoke_device( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): + if not session_id or len(session_id) > 64 or len(session_id) < 1: + raise HTTPException(status_code=400, detail="Invalid session ID") + s = ( db.query(DeviceSession) .filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 208af07..bc0691b 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -1,4 +1,5 @@ from datetime import datetime +import html import logging from pathlib import Path import os @@ -6,19 +7,31 @@ import re import uuid import asyncio import time -from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form +import unicodedata +from collections import defaultdict, deque +from difflib import SequenceMatcher +from types import SimpleNamespace +from typing import Any +from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect, UploadFile, File, Form, Request from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from .account import convert_user from constants import OWNER_USERNAME -from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse +from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope, MessageFile, DMFile, Reaction, ReactionRequest, ReactionResponse, DMReaction, DMReactionRequest, DMReactionResponse, UpdateLog from push_service import push_service from PIL import Image import io import json +from pydantic import BaseModel from better_profanity import profanity as _bp +from security.audit import log_access, log_dm, log_public_chat, log_security +from security.profanity import contains_profanity +from security.rate_limit import rate_limit_per_ip +from websocket.utils import authenticate_user + +from models import FcmToken router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -32,6 +45,142 @@ FILES_ENCRYPTED_DIR = FILES_BASE_DIR / "encrypted" os.makedirs(FILES_NORMAL_DIR, exist_ok=True) os.makedirs(FILES_ENCRYPTED_DIR, exist_ok=True) +_SPAM_WINDOW_SECONDS = 45 +_SPAM_SIMILARITY_THRESHOLD = 0.88 +_SPAM_MESSAGE_LIMIT = 5 +_BURST_WINDOW_SECONDS = 30 +_BURST_COUNT_THRESHOLD = 20 +_SHORT_MESSAGE_LENGTH = 8 +_SHORT_MESSAGE_REPEAT_LIMIT = 4 + +_recent_message_cache: dict[int, deque[tuple[str, str, float, int]]] = defaultdict(deque) # (normalized, content, timestamp, message_id) +_message_rate_cache: dict[int, deque[tuple[float, int]]] = defaultdict(deque) # (timestamp, message_id) +_burst_last_logged: dict[int, float] = {} + + +def _normalize_for_spam(text: str) -> str: + normalized = unicodedata.normalize("NFKC", text or "").casefold() + # Remove whitespace and punctuation while keeping alphanumerics + cleaned = re.sub(r"[^0-9a-zа-яё]+", "", normalized, flags=re.IGNORECASE) + return cleaned + + +def _monitor_public_message_activity(user: User, content: str, message_id: int, db: Session) -> None: + now = time.time() + + def suspend(reason: str, event: str, message_ids_to_delete: list[int] = None, **extra: Any) -> None: + if user.suspended or user.id == 1: + return + + # Delete spam messages that triggered the ban + if message_ids_to_delete: + try: + deleted_count = db.query(Message).filter(Message.id.in_(message_ids_to_delete)).delete(synchronize_session=False) + db.commit() + logger.info(f"Deleted {deleted_count} spam messages for user {user.id}") + except Exception as e: + logger.error(f"Failed to delete spam messages: {e}") + db.rollback() + + user.suspended = True + user.suspension_reason = reason + db.commit() + log_security( + event, + severity="warning", + user_id=user.id, + username=user.username, + reason=reason, + deleted_messages=len(message_ids_to_delete) if message_ids_to_delete else 0, + **extra, + ) + try: + asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason)) + except Exception: + pass + + # Rate tracking for burst detection + rate_bucket = _message_rate_cache[user.id] + rate_bucket.append((now, message_id)) + while rate_bucket and now - rate_bucket[0][0] > _BURST_WINDOW_SECONDS: + rate_bucket.popleft() + + burst_count = len(rate_bucket) + if burst_count >= _BURST_COUNT_THRESHOLD: + last_logged = _burst_last_logged.get(user.id) + if not last_logged or now - last_logged > _BURST_WINDOW_SECONDS: + log_security( + "public_message_burst", + severity="warning", + user_id=user.id, + username=user.username, + count=burst_count, + window_seconds=_BURST_WINDOW_SECONDS, + ) + _burst_last_logged[user.id] = now + + # Get all message IDs from the burst window + burst_message_ids = [msg_id for _, msg_id in rate_bucket] + suspend( + "Automatic suspension: excessive message rate", + "auto_suspension_public_burst", + message_ids_to_delete=burst_message_ids, + count=burst_count, + window_seconds=_BURST_WINDOW_SECONDS, + ) + return + + # Similarity-based spam detection + normalized = _normalize_for_spam(content) + history = _recent_message_cache[user.id] + while history and now - history[0][2] > _SPAM_WINDOW_SECONDS: + history.popleft() + + prior_same = sum(1 for prev_norm, _, _, _ in history if prev_norm == normalized) + prior_similar = sum( + 1 + for prev_norm, _, _, _ in history + if prev_norm and normalized and prev_norm != normalized and SequenceMatcher(None, normalized, prev_norm).ratio() >= _SPAM_SIMILARITY_THRESHOLD + ) + + history.append((normalized, content, now, message_id)) + + total_matches = prior_same + prior_similar + 1 + + if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT: + # Get message IDs of all matching short messages + spam_message_ids = [msg_id for prev_norm, _, _, msg_id in history if prev_norm == normalized] + spam_message_ids.append(message_id) # Include current message + suspend( + "Automatic suspension: repeated short messages", + "auto_suspension_public_spam", + message_ids_to_delete=spam_message_ids, + occurrences=prior_same + 1, + window_seconds=_SPAM_WINDOW_SECONDS, + match_type="short", + ) + return + + if total_matches >= _SPAM_MESSAGE_LIMIT: + # Get message IDs of all matching similar messages + spam_message_ids = [] + for prev_norm, _, _, msg_id in history: + if prev_norm == normalized: + spam_message_ids.append(msg_id) + elif prev_norm and normalized and prev_norm != normalized: + similarity = SequenceMatcher(None, normalized, prev_norm).ratio() + if similarity >= _SPAM_SIMILARITY_THRESHOLD: + spam_message_ids.append(msg_id) + spam_message_ids.append(message_id) # Include current message + suspend( + "Automatic suspension: repeated similar public messages", + "auto_suspension_public_spam", + message_ids_to_delete=spam_message_ids, + similar_messages=total_matches, + window_seconds=_SPAM_WINDOW_SECONDS, + match_type="similar", + ) + def convert_message(msg: Message) -> dict: # Group reactions by emoji @@ -51,8 +200,8 @@ def convert_message(msg: Message) -> dict: "username": reaction.user.display_name }) - # Handle deleted users - if msg.author.deleted: + # Handle deleted or suspended users + if msg.author.deleted or msg.author.suspended: username = f"Deleted User #{msg.author.id}" profile_picture = None verified = False @@ -85,7 +234,7 @@ def convert_message(msg: Message) -> dict: } -def convert_dm_envelope(envelope: DMEnvelope) -> dict: +def convert_dm_envelope(db: Session, envelope: DMEnvelope) -> dict: # Group reactions by emoji reactions_dict = {} if envelope.reactions: @@ -104,13 +253,10 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: }) # Get sender info for verified status - from models import User - from dependencies import get_db - db = next(get_db()) sender = db.query(User).filter(User.id == envelope.sender_id).first() - # Handle deleted users - if sender and sender.deleted: + # Handle deleted or suspended users + if sender and (sender.deleted or sender.suspended): sender_verified = False else: sender_verified = sender.verified if sender else False @@ -138,91 +284,51 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: ] } -# для тех кто читает этот код я эти маты не писал -# мат писал ии а я сам не матерюсь)) -# - denis0001-dev -_RU_EXTRA = [ - "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", - "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", - "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", - "долбоёб", "долбоеб", "дебил" -] -_bp.load_censor_words() -_bp.add_censor_words(_RU_EXTRA) - -# Additional phrase-level filters (case-insensitive) -_PHRASE_PATTERNS: list[re.Pattern] = [ - re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), - re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), -] - -def _mask_span(text: str, start: int, end: int) -> str: - return text[:start] + ("\\*" * (end - start)) + text[end:] - -def _apply_phrase_filters(text: str) -> str: - result = text - for pattern in _PHRASE_PATTERNS: - # Replace all occurrences; iterate until no more matches to avoid overlapping issues - while True: - m = pattern.search(result) - if not m: - break - result = _mask_span(result, m.start(), m.end()) - return result - -def filter_profanity(text: str) -> str: - preprocessed = _apply_phrase_filters(text) - return _bp.censor(preprocessed, censor_char="\\*") - - -@router.post("/send_message") -async def send_message( - request: SendMessageRequest | None = None, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db), - # Optional multipart form support - payload: str | None = Form(default=None), - files: list[UploadFile] = File(default=[]), -): - # If payload is provided, prefer it for multipart requests - if payload and request is None: - # Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null} - try: - obj = json.loads(payload) - content = obj.get("content", "") - reply_to_id = obj.get("reply_to_id", None) - request = SendMessageRequest(content=content, reply_to_id=reply_to_id) - except Exception: - raise HTTPException(status_code=400, detail="Invalid payload JSON") - - if request.reply_to_id: +async def _send_message_internal( + message_request: SendMessageRequest, + current_user: User, + db: Session, + files: list[UploadFile] = [], +) -> dict: + """Internal function to send a message without requiring a Request object. + + This can be called from both HTTP endpoints and WebSocket handlers. + """ + if message_request.reply_to_id: # Check if the message being replied to exists - original_message = db.query(Message).filter(Message.id == request.reply_to_id).first() + original_message = db.query(Message).filter(Message.id == message_request.reply_to_id).first() if not original_message: raise HTTPException(status_code=404, detail="Original message not found") - if not request.content.strip(): + raw_content = message_request.content.strip() + + if not raw_content: raise HTTPException( status_code=400, detail="No content provided" ) - # Apply profanity filter before storing - filtered_content = filter_profanity(request.content.strip()) + # Check for profanity and reject the message instead of censoring + if contains_profanity(raw_content): + raise HTTPException( + status_code=422, # Unprocessable Entity - content validation failed + detail="Message contains inappropriate content and cannot be sent" + ) - if len(filtered_content) > 4096: + # Escape content for safe HTML display + escaped_content = html.escape(raw_content, quote=False) + + if len(escaped_content) > 4096: raise HTTPException( status_code=400, detail="Message too long" ) new_message = Message( - content=filtered_content, + content=escaped_content, user_id=current_user.id, - reply_to_id=request.reply_to_id, + reply_to_id=message_request.reply_to_id, timestamp=datetime.now() ) @@ -293,19 +399,156 @@ async def send_message( # Realtime broadcast for HTTP uploads as well try: - from .messaging import messagingManager # self import safe here await messagingManager.broadcast({ "type": "newMessage", "data": convert_message(new_message) - }) + }, db) except Exception: pass - return {"status": "success", "message": convert_message(new_message)} + _monitor_public_message_activity(current_user, raw_content, new_message.id, db) + + message_payload = convert_message(new_message) + + # Prepare log fields + log_fields = { + "message_id": new_message.id, + "user_id": current_user.id, + "username": current_user.username, + "reply_to": new_message.reply_to_id, + "attachments": len(new_message.files or []), + "length": len(new_message.content), + "suspended": current_user.suspended, + "content": new_message.content, + } + + log_public_chat("message_created", **log_fields) + + return {"status": "success", "message": message_payload} + + +@router.post("/send_message") +@rate_limit_per_ip("30/minute") +async def send_message( + request: Request, + message_request: SendMessageRequest | None = None, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), + # Optional multipart form support + payload: str | None = Form(default=None), + files: list[UploadFile] = File(default=[]), +): + # If payload is provided, prefer it for multipart requests + if payload and message_request is None: + # Expect JSON: {"type":"text","data":{"content": str}, "reply_to_id": number|null} + try: + obj = json.loads(payload) + content = obj.get("content", "") + reply_to_id = obj.get("reply_to_id", None) + message_request = SendMessageRequest(content=content, reply_to_id=reply_to_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid payload JSON") + + if not message_request: + raise HTTPException(status_code=400, detail="Missing request data") + + return await _send_message_internal(message_request, current_user, db, files) + + +class RegisterFcmRequest(BaseModel): + token: str + + +@router.post("/push/register") +async def register_fcm_token(request: Request, body: RegisterFcmRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """ + Register or update an FCM token for the authenticated user. + """ + token = body.token.strip() if body and body.token else None + if not token: + raise HTTPException(status_code=400, detail="Missing token") + + try: + # If token already exists (from another device), reassign it to this user. + token_row = db.query(FcmToken).filter(FcmToken.token == token).first() + if token_row: + token_row.user_id = current_user.id + else: + # Create new token record (allow multiple tokens per user) + new = FcmToken(user_id=current_user.id, token=token) + db.add(new) + db.commit() + logger.info(f"Registered FCM token for user {current_user.id}: {token}") + except Exception as e: + try: + db.rollback() + except Exception: + pass + raise HTTPException(status_code=500, detail="Failed to save token") + + return {"status": "success"} + + +@router.post("/push/unregister") +async def unregister_fcm_token(request: Request, body: RegisterFcmRequest | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """ + Unregister an FCM token. If `body.token` provided, remove only that token for the user. + If no token provided, remove all tokens for the user. + """ + try: + if body and body.token: + db.query(FcmToken).filter(FcmToken.user_id == current_user.id, FcmToken.token == body.token.strip()).delete() + else: + db.query(FcmToken).filter(FcmToken.user_id == current_user.id).delete() + db.commit() + except Exception as e: + try: + db.rollback() + except Exception: + pass + raise HTTPException(status_code=500, detail="Failed to remove token") + + return {"status": "success"} + + +@router.post("/push/test") +async def push_test(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """ + Send a test push to the current user's registered FCM token (for manual testing). + """ + try: + fcm_rows = db.query(FcmToken).filter(FcmToken.user_id == current_user.id).all() + if not fcm_rows: + raise HTTPException(status_code=404, detail="No FCM token registered for user") + + title = "FromChat test" + body = "This is a test push from the server" + data = {"type": "test", "timestamp": datetime.utcnow().isoformat()} + + # Use push_service which uses Admin SDK internally; attempt to send to all tokens + failures = [] + for fcm in fcm_rows: + try: + push_service._send_fcm_to_token(fcm.token, title, body, data) + except Exception as e: + logger.error(f"Failed to send test push to user {current_user.id} token {fcm.token}: {e}") + failures.append(str(e)) + + if failures and len(failures) == len(fcm_rows): + # All failed + raise HTTPException(status_code=500, detail=f"Failed to send push to any token: {failures}") + + return {"status": "success", "sent": len(fcm_rows) - len(failures), "failed": len(failures)} + except HTTPException: + raise + except Exception as e: + logger.error(f"push_test error: {e}") + raise HTTPException(status_code=500, detail="Internal error") @router.get("/get_messages") -async def get_messages(db: Session = Depends(get_db)): +@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse +async def get_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): messages = db.query(Message).order_by(Message.timestamp.asc()).all() messages_data = [] @@ -318,8 +561,47 @@ async def get_messages(db: Session = Depends(get_db)): } +class MarkReadRequest(BaseModel): + messageIds: list[int] + + +@router.get("/messages/new") +@rate_limit_per_ip("60/minute") +async def get_new_messages(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """ + Return unread public messages (Message.is_read == False). + """ + new_messages = db.query(Message).filter(Message.is_read == False).order_by(Message.timestamp.asc()).all() + messages_data = [convert_message(msg) for msg in new_messages] + return {"status": "success", "messages": messages_data} + + +@router.post("/messages/read") +@rate_limit_per_ip("60/minute") +async def mark_messages_read(request: Request, read_request: MarkReadRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + """ + Mark specified message IDs as read (set Message.is_read = True). + """ + if not read_request or not isinstance(read_request.messageIds, list) or len(read_request.messageIds) == 0: + return {"status": "success", "updated": 0} + + try: + updated_count = db.query(Message).filter(Message.id.in_(read_request.messageIds)).update({Message.is_read: True}, synchronize_session=False) + db.commit() + except Exception as e: + try: + db.rollback() + except Exception: + pass + raise HTTPException(status_code=500, detail="Failed to mark messages as read") + + return {"status": "success", "updated": int(updated_count)} + + @router.post("/dm/send") +@rate_limit_per_ip("20/minute") async def dm_send( + request: Request, payload: dict | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -342,9 +624,25 @@ async def dm_send( if key not in payload: raise HTTPException(status_code=400, detail=f"Missing {key}") + try: + recipient_id = int(payload["recipientId"]) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid recipientId") + + if recipient_id <= 0: + raise HTTPException(status_code=400, detail="Invalid recipientId") + + if recipient_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot send DM to yourself") + + # Verify recipient exists + recipient = db.query(User).filter(User.id == recipient_id).first() + if not recipient or recipient.deleted or recipient.suspended: + raise HTTPException(status_code=404, detail="Recipient not found") + env = DMEnvelope( sender_id=current_user.id, - recipient_id=int(payload["recipientId"]), + recipient_id=recipient_id, iv_b64=payload["iv"], ciphertext_b64=payload["ciphertext"], salt_b64=payload["salt"], @@ -404,6 +702,7 @@ async def dm_send( ) db.add(df) db.commit() + db.refresh(env) # Send push notification for DM try: @@ -433,6 +732,16 @@ async def dm_send( except Exception: pass + log_dm( + "message_sent", + dm_envelope_id=env.id, + sender_id=current_user.id, + sender_username=current_user.username, + recipient_id=env.recipient_id, + attachment_count=len(env.files or []), + reply_to=env.reply_to_id, + ) + return {"status": "ok", "id": env.id} def convert_envelopes(envs: list[DMEnvelope]): @@ -456,7 +765,8 @@ def convert_envelopes(envs: list[DMEnvelope]): } @router.get("/dm/fetch") -async def dm_fetch(since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse +async def dm_fetch(request: Request, since: int | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): q = db.query(DMEnvelope).filter(DMEnvelope.recipient_id == current_user.id) if since: q = q.filter(DMEnvelope.id > since) @@ -464,7 +774,19 @@ async def dm_fetch(since: int | None = None, current_user: User = Depends(get_cu @router.get("/dm/history/{other_user_id}") -async def dm_history(other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse +async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + if other_user_id <= 0: + raise HTTPException(status_code=400, detail="Invalid user ID") + + if other_user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot get history with yourself") + + # Verify other user exists + other_user = db.query(User).filter(User.id == other_user_id).first() + if not other_user or other_user.deleted or other_user.suspended: + raise HTTPException(status_code=404, detail="User not found") + return convert_envelopes( db.query(DMEnvelope) .filter( @@ -477,7 +799,8 @@ async def dm_history(other_user_id: int, current_user: User = Depends(get_curren @router.get("/dm/conversations") -async def get_dm_conversations(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): +@rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse +async def get_dm_conversations(request: Request, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): # Get all DM conversations where current user is involved conversations_query = db.query(DMEnvelope).filter( (DMEnvelope.sender_id == current_user.id) | (DMEnvelope.recipient_id == current_user.id) @@ -505,7 +828,7 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d result.append({ "user": convert_user(other_user), - "lastMessage": convert_dm_envelope(latest_message), + "lastMessage": convert_dm_envelope(db, latest_message), "unreadCount": unread_count }) @@ -518,28 +841,74 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d } -@router.put("/edit_message/{message_id}") -async def edit_message( +async def _edit_message_internal( message_id: int, - request: EditMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): + edit_request: EditMessageRequest, + current_user: User, + db: Session +) -> dict: + """Internal function to edit a message without requiring a Request object. + + This can be called from both HTTP endpoints and WebSocket handlers. + """ message = db.query(Message).filter(Message.id == message_id).first() if not message: raise HTTPException(status_code=404, detail="Message not found") if message.user_id != current_user.id: raise HTTPException(status_code=403, detail="You can only edit your own messages") - if not request.content.strip(): + raw_content = edit_request.content.strip() + + if not raw_content: raise HTTPException(status_code=400, detail="Message content cannot be empty") - message.content = request.content.strip() + + original_content = message.content + + # Check for profanity and reject the edit instead of censoring + if contains_profanity(raw_content): + raise HTTPException( + status_code=422, # Unprocessable Entity - content validation failed + detail="Message contains inappropriate content and cannot be sent" + ) + + escaped_content = html.escape(raw_content, quote=False) + + if len(escaped_content) > 4096: + raise HTTPException(status_code=400, detail="Message too long") + + message.content = escaped_content message.is_edited = True db.commit() db.refresh(message) - return {"status": "success", "message": convert_message(message)} + payload = convert_message(message) + + # Prepare log fields + log_fields = { + "message_id": message.id, + "user_id": current_user.id, + "username": current_user.username, + "reply_to": message.reply_to_id, + "content": message.content, + "previous_content": original_content, + } + + log_public_chat("message_edited", **log_fields) + + return {"status": "success", "message": payload} + + +@router.put("/edit_message/{message_id}") +@rate_limit_per_ip("20/minute") +async def edit_message( + request: Request, + message_id: int, + edit_request: EditMessageRequest, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + return await _edit_message_internal(message_id, edit_request, current_user, db) @router.delete("/delete_message/{message_id}") @@ -557,28 +926,40 @@ async def delete_message( if current_user.username != OWNER_USERNAME and message.user_id != current_user.id: raise HTTPException(status_code=403, detail="You can only delete your own messages") + original_content = message.content db.delete(message) db.commit() + log_public_chat( + "message_deleted", + message_id=message_id, + actor_id=current_user.id, + actor_username=current_user.username, + original_author_id=message.user_id, + content=original_content, + ) + return {"status": "success", "message_id": message_id} @router.post("/add_reaction") +@rate_limit_per_ip("50/minute") async def add_reaction( - request: ReactionRequest, + request: Request, + reaction_request: ReactionRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): # Check if message exists - message = db.query(Message).filter(Message.id == request.message_id).first() + message = db.query(Message).filter(Message.id == reaction_request.message_id).first() if not message: raise HTTPException(status_code=404, detail="Message not found") # Check if reaction already exists existing_reaction = db.query(Reaction).filter( - Reaction.message_id == request.message_id, + Reaction.message_id == reaction_request.message_id, Reaction.user_id == current_user.id, - Reaction.emoji == request.emoji + Reaction.emoji == reaction_request.emoji ).first() if existing_reaction: @@ -588,9 +969,9 @@ async def add_reaction( else: # Add new reaction new_reaction = Reaction( - message_id=request.message_id, + message_id=reaction_request.message_id, user_id=current_user.id, - emoji=request.emoji + emoji=reaction_request.emoji ) db.add(new_reaction) action = "added" @@ -600,34 +981,46 @@ async def add_reaction( # Refresh message to get updated reactions db.refresh(message) + message_data = convert_message(message) + # Broadcast reaction update try: - from .messaging import messagingManager await messagingManager.broadcast({ "type": "reactionUpdate", "data": { - "message_id": request.message_id, - "emoji": request.emoji, + "message_id": reaction_request.message_id, + "emoji": reaction_request.emoji, "action": action, "user_id": current_user.id, "username": current_user.username, - "reactions": convert_message(message)["reactions"] + "reactions": message_data["reactions"] } - }) + }, db) except Exception: pass - return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]} + log_public_chat( + "reaction_update", + message_id=reaction_request.message_id, + user_id=current_user.id, + username=current_user.username, + action=action, + emoji=reaction_request.emoji, + ) + + return {"status": "success", "action": action, "reactions": message_data["reactions"]} @router.post("/dm/add_reaction") +@rate_limit_per_ip("50/minute") async def add_dm_reaction( - request: DMReactionRequest, + request: Request, + reaction_request: DMReactionRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): # Check if DM envelope exists - envelope = db.query(DMEnvelope).filter(DMEnvelope.id == request.dm_envelope_id).first() + envelope = db.query(DMEnvelope).filter(DMEnvelope.id == reaction_request.dm_envelope_id).first() if not envelope: raise HTTPException(status_code=404, detail="DM envelope not found") @@ -637,9 +1030,9 @@ async def add_dm_reaction( # Check if reaction already exists existing_reaction = db.query(DMReaction).filter( - DMReaction.dm_envelope_id == request.dm_envelope_id, + DMReaction.dm_envelope_id == reaction_request.dm_envelope_id, DMReaction.user_id == current_user.id, - DMReaction.emoji == request.emoji + DMReaction.emoji == reaction_request.emoji ).first() if existing_reaction: @@ -649,9 +1042,9 @@ async def add_dm_reaction( else: # Add new reaction new_reaction = DMReaction( - dm_envelope_id=request.dm_envelope_id, + dm_envelope_id=reaction_request.dm_envelope_id, user_id=current_user.id, - emoji=request.emoji + emoji=reaction_request.emoji ) db.add(new_reaction) action = "added" @@ -661,23 +1054,34 @@ async def add_dm_reaction( # Refresh envelope to get updated reactions db.refresh(envelope) + envelope_data = convert_dm_envelope(db, envelope) + # Broadcast reaction update to both participants try: await messagingManager.broadcast({ "type": "dmReactionUpdate", "data": { - "dm_envelope_id": request.dm_envelope_id, - "emoji": request.emoji, + "dm_envelope_id": reaction_request.dm_envelope_id, + "emoji": reaction_request.emoji, "action": action, "user_id": current_user.id, "username": current_user.username, - "reactions": convert_dm_envelope(envelope)["reactions"] + "reactions": envelope_data["reactions"] } - }) + }, db) except Exception: pass - return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]} + log_dm( + "reaction_update", + dm_envelope_id=reaction_request.dm_envelope_id, + user_id=current_user.id, + username=current_user.username, + action=action, + emoji=reaction_request.emoji, + ) + + return {"status": "success", "action": action, "reactions": envelope_data["reactions"]} class MessaggingSocketManager: @@ -687,529 +1091,233 @@ class MessaggingSocketManager: self.online_users: set[int] = set() self.typing_users: dict[int, float] = {} # user_id -> timestamp self.dm_typing_users: dict[int, dict[int, float]] = {} # user_id -> {recipient_id -> timestamp} + self.typing_state: dict[int, bool] = {} # user_id -> is_typing (for public chat) + self.dm_typing_state: dict[int, dict[int, bool]] = {} # user_id -> {recipient_id -> is_typing} self.ws_subscriptions: dict[WebSocket, set[int]] = {} # websocket -> set of subscribed user_ids self._cleanup_task = None + # Update system: sequence numbers and batching + self.sequence_numbers: dict[int, int] = {} # user_id -> current sequence number + self.pending_updates: dict[WebSocket, list[dict]] = {} # websocket -> list of pending updates + self.update_batch_tasks: dict[WebSocket, asyncio.Task] = {} # websocket -> batch task + self.last_seq_by_ws: dict[WebSocket, int] = {} # websocket -> last received sequence number + self.stored_sequences: dict[tuple[int, int], bool] = {} # (user_id, sequence) -> stored flag + self.recent_updates: dict[WebSocket, set[str]] = {} # websocket -> set of recent update signatures + self._sequence_lock: dict[int, asyncio.Lock] = {} # user_id -> lock for sequence generation async def send_error(self, websocket: WebSocket, type: str, e: HTTPException): await websocket.send_json({"type": type, "error": {"code": e.status_code, "detail": e.detail}}) + async def _get_next_sequence(self, user_id: int) -> int: + """Get the next sequence number for a user (shared across all their connections) - thread-safe""" + if user_id not in self._sequence_lock: + self._sequence_lock[user_id] = asyncio.Lock() + + async with self._sequence_lock[user_id]: + if user_id not in self.sequence_numbers: + self.sequence_numbers[user_id] = 0 + self.sequence_numbers[user_id] += 1 + return self.sequence_numbers[user_id] + + def _get_update_signature(self, update: dict) -> str: + """Generate a unique signature for an update to detect duplicates""" + import hashlib + import json + + update_type = update.get("type", "") + data = update.get("data", {}) + + # Create signature based on update type and key identifying fields + if update_type == "newMessage": + # Deduplicate by message ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "messageEdited": + # Deduplicate by message ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "messageDeleted": + # Deduplicate by message ID + sig_data = {"type": update_type, "id": data.get("id") or data.get("message_id")} + elif update_type == "dmNew": + # Deduplicate by envelope ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "dmEdited": + # Deduplicate by envelope ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "dmDeleted": + # Deduplicate by envelope ID + sig_data = {"type": update_type, "id": data.get("id")} + elif update_type == "reactionUpdate": + # Deduplicate by message ID + emoji + user ID + sig_data = {"type": update_type, "messageId": data.get("message_id"), "emoji": data.get("emoji"), "userId": data.get("userId")} + elif update_type == "dmReactionUpdate": + # Deduplicate by envelope ID + emoji + user ID + sig_data = {"type": update_type, "dmEnvelopeId": data.get("dm_envelope_id"), "emoji": data.get("emoji"), "userId": data.get("userId")} + elif update_type == "typing" or update_type == "stopTyping": + # Deduplicate by user ID (state tracking already handles this, but extra protection) + sig_data = {"type": update_type, "userId": data.get("userId")} + elif update_type == "dmTyping" or update_type == "stopDmTyping": + # Deduplicate by user ID (recipient ID is implicit - this update is sent TO the recipient) + sig_data = {"type": update_type, "userId": data.get("userId")} + elif update_type == "statusUpdate": + # Deduplicate by user ID + sig_data = {"type": update_type, "userId": data.get("userId")} + else: + # For unknown types, use full data (less efficient but safe) + sig_data = {"type": update_type, "data": data} + + # Create hash of signature data + sig_json = json.dumps(sig_data, sort_keys=True) + return hashlib.md5(sig_json.encode()).hexdigest() + + def _add_update(self, websocket: WebSocket, update: dict): + """Add an update to the pending batch for a WebSocket (with deduplication)""" + if websocket not in self.pending_updates: + self.pending_updates[websocket] = [] + + # Check for duplicates + signature = self._get_update_signature(update) + if websocket not in self.recent_updates: + self.recent_updates[websocket] = set() + + # Skip if this exact update was recently added + if signature in self.recent_updates[websocket]: + logger.warning(f"Update was skipped due to duplicate signature {signature}") + return + + # Add to pending updates and track signature + self.pending_updates[websocket].append(update) + self.recent_updates[websocket].add(signature) + + # Limit recent updates cache size (keep last 100 signatures per websocket) + if len(self.recent_updates[websocket]) > 1: + self.recent_updates[websocket] = set(list(self.recent_updates[websocket])[-1]) + + async def _flush_updates(self, websocket: WebSocket, db: Session | None = None): + """Flush pending updates for a WebSocket connection""" + if websocket not in self.pending_updates or not self.pending_updates[websocket]: + return + + updates = self.pending_updates[websocket] + self.pending_updates[websocket] = [] + + # Clear recent updates cache after flushing (updates are now sent, can be re-added if needed) + if websocket in self.recent_updates: + # Keep only the last 50 signatures to allow some deduplication across batches + recent_list = list(self.recent_updates[websocket]) + if len(recent_list) > 50: + self.recent_updates[websocket] = set(recent_list[-50:]) + else: + # Keep all if under limit + pass + + if updates: + user_id = self.user_by_ws.get(websocket) + if not user_id: + # No user associated - this shouldn't happen for authenticated connections + # Skip sending to avoid seq: 0 issues + logger.warning(f"Attempted to flush updates for unauthenticated websocket, skipping") + return + + seq = await self._get_next_sequence(user_id) + + # Store updates in database for gap detection (only once per user per sequence) + if db: + sequence_key = (user_id, seq) + # Double-check pattern: check again after getting sequence (in case another connection got the same sequence) + if sequence_key not in self.stored_sequences: + try: + import json + # Store the entire batch as a single record + update_log = UpdateLog( + user_id=user_id, + sequence=seq, + updates=json.dumps(updates) + ) + db.add(update_log) + db.commit() + self.stored_sequences[sequence_key] = True + except Exception as e: + # Always rollback on error to reset session state + try: + db.rollback() + except Exception: + pass # Ignore rollback errors + + # If we get a UNIQUE constraint error, it means another connection already stored this sequence + if "UNIQUE constraint" in str(e) or "IntegrityError" in str(e.__class__.__name__): + # Mark as stored to prevent future attempts + self.stored_sequences[sequence_key] = True + logger.debug(f"Update sequence {seq} for user {user_id} already stored by another connection") + else: + logger.error(f"Failed to store updates in database: {e}") + else: + # Already stored, skip + logger.debug(f"Update sequence {seq} for user {user_id} already marked as stored") + + await websocket.send_json({ + "type": "updates", + "seq": seq, + "updates": updates + }) + + async def _schedule_batch_flush(self, websocket: WebSocket, db: Session | None = None): + """Schedule a batch flush after a delay (50-100ms)""" + if websocket in self.update_batch_tasks: + self.update_batch_tasks[websocket].cancel() + + async def flush_after_delay(): + await asyncio.sleep(0.075) # 75ms delay for batching + await self._flush_updates(websocket, db) + if websocket in self.update_batch_tasks: + del self.update_batch_tasks[websocket] + + self.update_batch_tasks[websocket] = asyncio.create_task(flush_after_delay()) + + async def _send_update(self, websocket: WebSocket, update_type: str, update_data: dict, db: Session | None = None): + """Send an update (will be batched)""" + self._add_update(websocket, {"type": update_type, "data": update_data}) + await self._schedule_batch_flush(websocket, db) + async def handle_connection(self, websocket: WebSocket, db: Session): # Initialize subscriptions for this connection self.ws_subscriptions[websocket] = set() + + # Import here to avoid circular import + from websocket.handlers import handler_registry while True: - data = await websocket.receive_json() - type = data["type"] - - def get_current_user_inner() -> User | None: - if data["credentials"]: - return get_current_user( - HTTPAuthorizationCredentials( - scheme=data["credentials"]["scheme"], - credentials=data["credentials"]["credentials"] - ), - db - ) - else: - return None - - if type == "ping": + try: + data = await websocket.receive_json() + except Exception as e: + logger.error(f"Error receiving WebSocket message: {e}") + break + + message_type = data["type"] + handler_info = handler_registry.get_handler(message_type) + + if handler_info: + handler, authRequired = handler_info try: - current_user = get_current_user_inner() - if current_user: - self.user_by_ws[websocket] = current_user.id - # Set user online in DB - current_user.online = True - current_user.last_seen = datetime.now() - db.commit() - # Add to online users - self.online_users.add(current_user.id) - # Broadcast status change - await self.broadcast_status_change(current_user.id, True, current_user.last_seen.isoformat()) - else: - await websocket.send_json({ - "type": "ping", - "data": { - "status": "error", - "error": { - "detail": "Failed to authorize", - "code": 401 - } - } - }) - except HTTPException: - await websocket.send_json({ - "type": "ping", - "data": { - "status": "error", - "error": { - "detail": "Failed to authorize", - "code": 401 - } - } - }) - await websocket.send_json({"type": "ping", "data": {"status": "success"}}) - elif type == "getMessages": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) + # Authenticate user before calling handler + user = authenticate_user(data, db, authRequired) + # Set user association for authenticated connections + if user: + self.user_by_ws[websocket] = user.id + + # Extract inner data to pass to handler + handler_data = data.get("data", {}) + result = await handler(self, websocket, db, user, handler_data) + # If handler returns a value, send it as a WebSocket message + if result is not None: + await websocket.send_json({"type": message_type, "data": result}) except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "sendMessage": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - - response = await send_message(request, current_user, db, None, []) - await self.broadcast({ - "type": "newMessage", - "data": response["message"] - }) - - await websocket.send_json({"type": type, "data": response}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "dmSend": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - payload = data["data"] - required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"] - for key in required: - if key not in payload: - raise HTTPException(status_code=400, detail=f"Missing {key}") - env = DMEnvelope( - sender_id=current_user.id, - recipient_id=int(payload["recipientId"]), - iv_b64=payload["iv"], - ciphertext_b64=payload["ciphertext"], - salt_b64=payload["salt"], - iv2_b64=payload["iv2"], - wrapped_mk_b64=payload["wrappedMk"], - reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, - ) - db.add(env) - db.commit() - db.refresh(env) - - payload = { - "type": "dmNew", - "data": { - "id": env.id, - "senderId": env.sender_id, - "recipientId": env.recipient_id, - "iv": env.iv_b64, - "ciphertext": env.ciphertext_b64, - "salt": env.salt_b64, - "iv2": env.iv2_b64, - "wrappedMk": env.wrapped_mk_b64, - "timestamp": env.timestamp.isoformat(), - "replyToId": env.reply_to_id, - } - } - - # Send push notification for DM - try: - await push_service.send_dm_notification(db, env, current_user) - except Exception as e: - logger.error(f"Failed to send push notification for DM {env.id}: {e}") - - await self.send_to_user(env.recipient_id, payload); - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); - await self.send_to_user(env.sender_id, payload); - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "editMessage": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - message_id = data["data"]["message_id"] - request: EditMessageRequest = EditMessageRequest.model_validate(data["data"]) - - response = await edit_message(message_id, request, current_user, db) - await self.broadcast({ - "type": "messageEdited", - "data": response["message"] - }) - - await websocket.send_json({"type": type, "data": response}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "dmEdit": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - payload = data["data"] - env_id = int(payload["id"]) - env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() - if not env: - raise HTTPException(status_code=404, detail="DM not found") - if env.sender_id != current_user.id: - raise HTTPException(status_code=403, detail="You can only edit your own messages") - - # Replace ciphertext and iv - env.iv_b64 = payload["iv"] - env.ciphertext_b64 = payload["ciphertext"] - env.iv2_b64 = payload["iv2"] - env.wrapped_mk_b64 = payload["wrappedMk"] - env.salt_b64 = payload["salt"] - db.commit() - db.refresh(env) - - payload_ws = { - "type": "dmEdited", - "data": { - "id": env.id, - "senderId": env.sender_id, - "recipientId": env.recipient_id, - "iv": env.iv_b64, - "ciphertext": env.ciphertext_b64, - "iv2": env.iv2_b64, - "wrappedMk": env.wrapped_mk_b64, - "salt": env.salt_b64, - "timestamp": env.timestamp.isoformat(), - } - } - await self.send_to_user(env.recipient_id, payload_ws) - await self.send_to_user(env.sender_id, payload_ws) - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "dmDelete": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - payload = data["data"] - env_id = int(payload["id"]) - env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() - if not env: - raise HTTPException(status_code=404, detail="DM not found") - if env.sender_id != current_user.id: - raise HTTPException(status_code=403, detail="You can only delete your own messages") - - db.delete(env) - db.commit() - - payload_ws = { - "type": "dmDeleted", - "data": { - "id": env_id, - "senderId": current_user.id, - "recipientId": payload.get("recipientId") - } - } - await self.send_to_user(env.recipient_id, payload_ws) - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) - await self.send_to_user(env.sender_id, payload_ws) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "deleteMessage": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - message_id = data["data"]["message_id"] - response = await delete_message(message_id, current_user, db) - await self.broadcast({ - "type": "messageDeleted", - "data": {"message_id": message_id} - }) - - await websocket.send_json({"type": type, "data": response}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "addReaction": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - request_data = data["data"] - reaction_request = ReactionRequest( - message_id=request_data["message_id"], - emoji=request_data["emoji"] - ) - - response = await add_reaction(reaction_request, current_user, db) - - # Broadcast reaction update - await self.broadcast({ - "type": "reactionUpdate", - "data": { - "message_id": request_data["message_id"], - "emoji": request_data["emoji"], - "action": response["action"], - "user_id": current_user.id, - "username": current_user.username, - "reactions": response["reactions"] - } - }) - - await websocket.send_json({"type": type, "data": response}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "addDmReaction": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - request_data = data["data"] - reaction_request = DMReactionRequest( - dm_envelope_id=request_data["dm_envelope_id"], - emoji=request_data["emoji"] - ) - - response = await add_dm_reaction(reaction_request, current_user, db) - - # Broadcast reaction update - await self.broadcast({ - "type": "dmReactionUpdate", - "data": { - "dm_envelope_id": request_data["dm_envelope_id"], - "emoji": request_data["emoji"], - "action": response["action"], - "user_id": current_user.id, - "username": current_user.username, - "reactions": response["reactions"] - } - }) - - await websocket.send_json({"type": type, "data": response}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "call_signaling": - # Forward WebRTC signaling between peers - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - payload = data.get("data") or {} - to_user_id = int(payload.get("toUserId") or 0) - if not to_user_id: - raise HTTPException(status_code=400, detail="Missing toUserId") - - # Ensure sender is set by the server - payload["fromUserId"] = current_user.id - payload["fromUsername"] = current_user.username - - await self.send_to_user(to_user_id, { - "type": "call_signaling", - "data": payload - }) - - # Optional ack - await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "call_video_toggle": - # Forward video toggle state between peers - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - payload = data.get("data") or {} - to_user_id = int(payload.get("toUserId") or 0) - if not to_user_id: - raise HTTPException(status_code=400, detail="Missing toUserId") - - # Ensure sender is set by the server - payload["fromUserId"] = current_user.id - - await self.send_to_user(to_user_id, { - "type": "call_signaling", - "data": { - "type": "call_video_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - } - }) - - await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "call_screen_share_toggle": - # Forward screen share toggle state between peers - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - payload = data.get("data") or {} - to_user_id = int(payload.get("toUserId") or 0) - if not to_user_id: - raise HTTPException(status_code=400, detail="Missing toUserId") - - # Ensure sender is set by the server - payload["fromUserId"] = current_user.id - - await self.send_to_user(to_user_id, { - "type": "call_signaling", - "data": { - "type": "call_screen_share_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - } - }) - - await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "subscribeStatus": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - user_id_to_subscribe = int(data["data"]["userId"]) - self.ws_subscriptions[websocket].add(user_id_to_subscribe) - - # Get current status of the user - target_user = db.query(User).filter(User.id == user_id_to_subscribe).first() - if target_user: - await websocket.send_json({ - "type": "statusUpdate", - "data": { - "userId": user_id_to_subscribe, - "online": target_user.online, - "lastSeen": target_user.last_seen.isoformat() - } - }) - else: - await websocket.send_json({ - "type": "subscribeStatus", - "data": {"status": "error", "error": "User not found"} - }) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "unsubscribeStatus": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - user_id_to_unsubscribe = int(data["data"]["userId"]) - self.ws_subscriptions[websocket].discard(user_id_to_unsubscribe) - - await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "typing": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - self.typing_users[current_user.id] = time.time() - - # Broadcast to all connected users - await self.broadcast({ - "type": "typing", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }) - - await websocket.send_json({"type": "typing", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "stopTyping": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - if current_user.id in self.typing_users: - del self.typing_users[current_user.id] - - # Broadcast to all connected users - await self.broadcast({ - "type": "stopTyping", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }) - - await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "dmTyping": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - recipient_id = int(data["data"]["recipientId"]) - - if current_user.id not in self.dm_typing_users: - self.dm_typing_users[current_user.id] = {} - self.dm_typing_users[current_user.id][recipient_id] = time.time() - - # Send only to recipient - await self.send_to_user(recipient_id, { - "type": "dmTyping", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }) - - await websocket.send_json({"type": "dmTyping", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) - elif type == "stopDmTyping": - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - recipient_id = int(data["data"]["recipientId"]) - - if current_user.id in self.dm_typing_users and recipient_id in self.dm_typing_users[current_user.id]: - del self.dm_typing_users[current_user.id][recipient_id] - if not self.dm_typing_users[current_user.id]: - del self.dm_typing_users[current_user.id] - - # Send only to recipient - await self.send_to_user(recipient_id, { - "type": "stopDmTyping", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }) - - await websocket.send_json({"type": "stopDmTyping", "data": {"status": "ok"}}) - except HTTPException as e: - await self.send_error(websocket, type, e) + await self.send_error(websocket, message_type, e) + except WebSocketDisconnect: + raise # Re-raise to close connection + except Exception as e: + logger.error(f"Error in handler for {message_type}: {e}") + await self.send_error(websocket, message_type, HTTPException(500, "Internal server error")) else: - await websocket.send_json({"type": type, "error": {"code": 400, "detail": "Invalid type"}}) + await websocket.send_json({"type": message_type, "error": {"code": 400, "detail": "Invalid type"}}) async def disconnect(self, websocket: WebSocket, code: int = 1000, message: str | None = None): try: @@ -1219,75 +1327,119 @@ class MessaggingSocketManager: async def connect(self, websocket: WebSocket, db: Session): await websocket.accept() + client_ip = websocket.client.host if websocket.client else None + log_access( + "ws_connect", + path=str(websocket.url.path), + ip=client_ip, + ) self.connections.append(websocket) + # Initialize update system for this connection + self.pending_updates[websocket] = [] + self.last_seq_by_ws[websocket] = 0 try: await self.handle_connection(websocket, db) except WebSocketDisconnect as e: logger.info(f"WebSocket disconnected with code {e.code}: {e.reason}") + log_access( + "ws_disconnect", + severity="warning" if e.code != 1000 else "info", + path=str(websocket.url.path), + ip=client_ip, + code=e.code, + reason=e.reason, + ) finally: + # Flush any pending updates before disconnecting + if websocket in self.pending_updates: + await self._flush_updates(websocket, db) + # Cancel any pending batch tasks + if websocket in self.update_batch_tasks: + self.update_batch_tasks[websocket].cancel() + del self.update_batch_tasks[websocket] # Cleanup connection self.connections.remove(websocket) if websocket in self.user_by_ws: user_id = self.user_by_ws[websocket] # Set user offline in DB - user = db.query(User).filter(User.id == user_id).first() - if user: - user.online = False - user.last_seen = datetime.now() - db.commit() - # Remove from online users - self.online_users.discard(user_id) - # Broadcast status change - await self.broadcast_status_change(user_id, False, user.last_seen.isoformat()) - del self.user_by_ws[websocket] + try: + # Ensure session is in a usable state + try: + db.rollback() + except Exception: + pass + + user = db.query(User).filter(User.id == user_id).first() + if user: + user.online = False + user.last_seen = datetime.now() + db.commit() + # Remove from online users + self.online_users.discard(user_id) + # Broadcast status change + await self.broadcast_status_change(user_id, False, user.last_seen.isoformat(), db) + except Exception as e: + logger.error(f"Failed to set user offline during cleanup: {e}") + try: + db.rollback() + except Exception: + pass + finally: + del self.user_by_ws[websocket] # Cleanup subscriptions if websocket in self.ws_subscriptions: del self.ws_subscriptions[websocket] + # Cleanup update system + if websocket in self.pending_updates: + del self.pending_updates[websocket] + if websocket in self.last_seq_by_ws: + del self.last_seq_by_ws[websocket] + if websocket in self.recent_updates: + del self.recent_updates[websocket] - async def broadcast(self, message: dict): + async def broadcast(self, message: dict, db: Session | None = None): + """Broadcast a message to all authenticated connections as an update (batched)""" + message_type = message.get("type", "") + update_data = message.get("data", {}) for websocket in self.connections: - await websocket.send_json(message) + # Only send to authenticated websockets (those with user_id set) + if websocket in self.user_by_ws: + await self._send_update(websocket, message_type, update_data, db) + + async def send_update_to_user(self, user_id: int, update_type: str, update_data: dict, db: Session | None = None): + """Send an update to a specific user (batched)""" + for websocket in self.connections: + if self.user_by_ws.get(websocket) == user_id: + await self._send_update(websocket, update_type, update_data, db) async def send_to_user(self, user_id: int, message: dict): + """Send a direct WebSocket message to a specific user (not batched)""" for websocket in self.connections: if self.user_by_ws.get(websocket) == user_id: await websocket.send_json(message) async def send_suspension_to_user(self, user_id: int, reason: str): - """Send suspension message to user's WebSocket connections""" - message = { - "type": "suspended", - "data": { - "reason": reason - } - } - await self.send_to_user(user_id, message) + """Send suspension message to user's WebSocket connections (as batched update)""" + await self.send_update_to_user(user_id, "suspended", { + "reason": reason + }) async def send_deletion_to_user(self, user_id: int): - """Send account deletion message to user's WebSocket connections""" - message = { - "type": "account_deleted", - "data": {} - } - await self.send_to_user(user_id, message) + """Send account deletion message to user's WebSocket connections (as batched update)""" + await self.send_update_to_user(user_id, "account_deleted", {}) - async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str): + async def broadcast_status_change(self, user_id: int, online: bool, last_seen: str, db: Session | None = None): """Broadcast status change to all connections that are subscribed to this user""" - message = { - "type": "statusUpdate", - "data": { - "userId": user_id, - "online": online, - "lastSeen": last_seen - } - } - # Send to all connections that have this user in their subscriptions for websocket in self.connections: if websocket in self.ws_subscriptions and user_id in self.ws_subscriptions[websocket]: - await websocket.send_json(message) + await self._send_update(websocket, "statusUpdate", { + "userId": user_id, + "online": online, + "lastSeen": last_seen + }, db) - async def cleanup_stale_typing_indicators(self): + async def cleanup_stale_typing_indicators(self, db: Session): """Periodically cleanup typing indicators that haven't been updated in 3+ seconds""" while True: try: @@ -1301,15 +1453,23 @@ class MessaggingSocketManager: ] for user_id in stale_public_typing: + was_typing = self.typing_state.get(user_id, False) del self.typing_users[user_id] - # Broadcast stop typing - await self.broadcast({ - "type": "stopTyping", - "data": { - "userId": user_id, - "username": "Unknown" # We don't have username here, frontend will handle - } - }) + + # Only send update if state changed (stopped typing) + if was_typing: + self.typing_state[user_id] = False + # Get username from database + user = db.query(User).filter(User.id == user_id).first() + username = user.username if user else "Unknown" + # Broadcast stop typing + await self.broadcast({ + "type": "stopTyping", + "data": { + "userId": user_id, + "username": username + } + }, db) # Cleanup DM typing indicators stale_dm_typing = [] @@ -1319,18 +1479,27 @@ class MessaggingSocketManager: stale_dm_typing.append((user_id, recipient_id)) for user_id, recipient_id in stale_dm_typing: + was_typing = False + if user_id in self.dm_typing_state: + was_typing = self.dm_typing_state[user_id].get(recipient_id, False) + if user_id in self.dm_typing_users and recipient_id in self.dm_typing_users[user_id]: del self.dm_typing_users[user_id][recipient_id] if not self.dm_typing_users[user_id]: del self.dm_typing_users[user_id] + + # Only send update if state changed (stopped typing) + if was_typing: + if user_id in self.dm_typing_state: + self.dm_typing_state[user_id][recipient_id] = False + # Get username from database + user = db.query(User).filter(User.id == user_id).first() + username = user.username if user else "Unknown" # Send stop typing to recipient - await self.send_to_user(recipient_id, { - "type": "stopDmTyping", - "data": { - "userId": user_id, - "username": "Unknown" # We don't have username here, frontend will handle - } - }) + await self.send_update_to_user(recipient_id, "stopDmTyping", { + "userId": user_id, + "username": username + }, db) # Wait 1 second before next cleanup await asyncio.sleep(1.0) @@ -1341,7 +1510,16 @@ class MessaggingSocketManager: def start_cleanup_task(self): """Start the cleanup task if not already running""" if self._cleanup_task is None or self._cleanup_task.done(): - self._cleanup_task = asyncio.create_task(self.cleanup_stale_typing_indicators()) + from db import SessionLocal + async def cleanup_with_db(): + while True: + try: + with SessionLocal() as db: + await self.cleanup_stale_typing_indicators(db) + except Exception as e: + logger.error(f"Error in cleanup task wrapper: {e}") + await asyncio.sleep(1.0) + self._cleanup_task = asyncio.create_task(cleanup_with_db()) messagingManager = MessaggingSocketManager() diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py new file mode 100644 index 0000000..ded5cb8 --- /dev/null +++ b/backend/routes/moderation.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from typing import List + +from constants import OWNER_USERNAME +from dependencies import get_current_user +from models import User +from security.audit import log_security +from security.profanity import add_to_blocklist, get_blocklist, remove_from_blocklist +from security.rate_limit import reset_rate_limit_for_ip, clear_all_rate_limits + + +class BlocklistUpdateRequest(BaseModel): + words: List[str] = Field(default_factory=list, min_items=1) + + +class UnblockIPRequest(BaseModel): + ip: str = Field(..., min_length=1) + + +router = APIRouter(prefix="/moderation", tags=["moderation"]) + + +def _ensure_owner(user: User) -> None: + if user.username != OWNER_USERNAME: + raise HTTPException(status_code=403, detail="Only owner can perform this action") + + +@router.get("/blocklist") +def list_blocklist(current_user: User = Depends(get_current_user)): + _ensure_owner(current_user) + return {"words": get_blocklist()} + + +@router.post("/blocklist") +def append_blocklist( + request: BlocklistUpdateRequest, + current_user: User = Depends(get_current_user) +): + _ensure_owner(current_user) + added, updated = add_to_blocklist(request.words) + log_security( + "blocklist_add", + actor=current_user.username, + actor_id=current_user.id, + added=added, + ) + return {"added": added, "words": updated} + + +@router.delete("/blocklist") +def delete_from_blocklist( + request: BlocklistUpdateRequest, + current_user: User = Depends(get_current_user) +): + _ensure_owner(current_user) + removed, updated = remove_from_blocklist(request.words) + log_security( + "blocklist_remove", + actor=current_user.username, + actor_id=current_user.id, + removed=removed, + ) + return {"removed": removed, "words": updated} + + +@router.post("/unblock-ip") +def unblock_ip( + request: UnblockIPRequest, + current_user: User = Depends(get_current_user) +): + """Unblock an IP address from rate limiting.""" + _ensure_owner(current_user) + ip = request.ip.strip() + + if not ip: + raise HTTPException(status_code=400, detail="IP address is required") + + cleared = reset_rate_limit_for_ip(ip) + + log_security( + "rate_limit_unblock", + actor=current_user.username, + actor_id=current_user.id, + ip=ip, + success=cleared, + ) + + if cleared: + return {"status": "success", "message": f"Rate limit cleared for IP: {ip}"} + else: + return {"status": "success", "message": f"No rate limit entries found for IP: {ip}"} + + +@router.post("/clear-all-rate-limits") +def clear_all_rate_limits_endpoint( + current_user: User = Depends(get_current_user) +): + """Clear all rate limit entries. Use with caution.""" + _ensure_owner(current_user) + + cleared = clear_all_rate_limits() + + log_security( + "rate_limit_clear_all", + actor=current_user.username, + actor_id=current_user.id, + entries_cleared=cleared, + ) + + return {"status": "success", "message": f"Cleared {cleared} rate limit entries"} + + + diff --git a/backend/routes/profile.py b/backend/routes/profile.py index ccba31b..31d1794 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -3,11 +3,11 @@ import re from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from fastapi.responses import FileResponse from sqlalchemy.orm import Session -from sqlalchemy import inspect, text from PIL import Image import os import uuid import io +from fastapi import Request from dependencies import get_db, get_current_user from models import User, UpdateBioRequest, UserProfileResponse @@ -15,9 +15,20 @@ from pydantic import BaseModel from validation import is_valid_username, is_valid_display_name from similarity import is_user_similar_to_verified from .messaging import messagingManager +from security.audit import log_security +from security.profanity import contains_profanity +from security.rate_limit import rate_limit_per_ip router = APIRouter() + +def _ensure_owner_unsuspended(user: User | None, db: Session): + if user and user.id == 1 and user.suspended: + user.suspended = False + user.suspension_reason = None + db.commit() + db.refresh(user) + # Request models class UpdateProfileRequest(BaseModel): username: str | None = None @@ -30,7 +41,9 @@ PROFILE_PICTURES_DIR = Path("data/uploads/pfp") os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) @router.post("/upload-profile-picture") +@rate_limit_per_ip("10/minute") async def upload_profile_picture( + request: Request, profile_picture: UploadFile = File(...), current_user: User = Depends(get_current_user), db: Session = Depends(get_db) @@ -104,20 +117,60 @@ async def get_user_profile( """ Get current user's profile information """ + _ensure_owner_unsuspended(current_user, db) + + return UserProfileResponse( + id=current_user.id, + username=current_user.username, + display_name=current_user.display_name, + profile_picture=current_user.profile_picture, + bio=current_user.bio, + online=current_user.online, + last_seen=current_user.last_seen, + created_at=current_user.created_at, + verified=current_user.verified, + suspended=current_user.suspended or False, + suspension_reason=current_user.suspension_reason, + deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted + ) + + +@router.get("/user/list") +async def list_users( + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db) +): + if current_user.id != 1: + raise HTTPException(status_code=403, detail="Only admin can list users") + + _ensure_owner_unsuspended(current_user, db) + + users = db.query(User).order_by(User.username.asc()).all() return { - "id": current_user.id, - "username": current_user.username, - "display_name": current_user.display_name, - "profile_picture": current_user.profile_picture, - "bio": current_user.bio, - "online": current_user.online, - "last_seen": current_user.last_seen, - "created_at": current_user.created_at + "users": [ + UserProfileResponse( + id=user.id, + username=user.username, + display_name=user.display_name, + profile_picture=user.profile_picture, + bio=user.bio, + online=user.online, + last_seen=user.last_seen, + created_at=user.created_at, + verified=user.verified, + suspended=user.suspended or False, + suspension_reason=user.suspension_reason, + deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted + ).model_dump() + for user in users + ] } @router.put("/user/profile") +@rate_limit_per_ip("10/minute") async def update_user_profile( - request: UpdateProfileRequest, + request: Request, + update_request: UpdateProfileRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -127,13 +180,18 @@ async def update_user_profile( updated = False # Update username if provided - if request.username is not None: - username = request.username.strip() + if update_request.username is not None: + username = update_request.username.strip() if not is_valid_username(username): raise HTTPException( status_code=400, detail="Имя пользователя должно быть от 3 до 20 символов и содержать только английские буквы, цифры, дефисы и подчеркивания" ) + if contains_profanity(username): + raise HTTPException( + status_code=400, + detail="Имя пользователя содержит запрещённые слова" + ) # Check if username is already taken by another user existing_user = db.query(User).filter(User.username == username, User.id != current_user.id).first() @@ -144,20 +202,25 @@ async def update_user_profile( updated = True # Update display name if provided - if request.display_name is not None: - display_name = request.display_name.strip() + if update_request.display_name is not None: + display_name = update_request.display_name.strip() if not is_valid_display_name(display_name): raise HTTPException( status_code=400, detail="Отображаемое имя должно быть от 1 до 64 символов и не может быть пустым" ) + if contains_profanity(display_name): + raise HTTPException( + status_code=400, + detail="Отображаемое имя содержит запрещённые слова" + ) current_user.display_name = display_name updated = True # Update bio if provided - if request.description is not None: - bio = request.description.strip() + if update_request.description is not None: + bio = update_request.description.strip() if len(bio) > 500: raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") @@ -182,18 +245,20 @@ async def update_user_profile( @router.put("/user/bio") +@rate_limit_per_ip("10/minute") async def update_user_bio( - request: UpdateBioRequest, + request: Request, + bio_request: UpdateBioRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): """ Update current user's bio """ - if len(request.bio) > 500: # Limit bio to 500 characters + if len(bio_request.bio) > 500: # Limit bio to 500 characters raise HTTPException(status_code=400, detail="Bio must be 500 characters or less") - current_user.bio = request.bio.strip() + current_user.bio = bio_request.bio.strip() db.commit() return { @@ -210,10 +275,32 @@ async def get_user_by_username( """ Get user profile by username """ + if not username or not is_valid_username(username): + raise HTTPException(status_code=400, detail="Invalid username format") + user = db.query(User).filter(User.username == username).first() if not user: raise HTTPException(status_code=404, detail="User not found") + + _ensure_owner_unsuspended(user, db) + + # Handle deleted or suspended users + if user.deleted or user.suspended: + return UserProfileResponse( + id=user.id, + username="deleted", + display_name="Deleted User", + profile_picture=None, + bio=None, + online=False, + last_seen=None, # Clear last seen timestamp + created_at=None, # Clear member since timestamp + verified=False, + suspended=False, + suspension_reason=None, + deleted=True + ) return UserProfileResponse( id=user.id, @@ -223,7 +310,11 @@ async def get_user_by_username( bio=user.bio, online=user.online, last_seen=user.last_seen, - created_at=user.created_at + created_at=user.created_at, + verified=user.verified, + suspended=user.suspended or False, + suspension_reason=user.suspension_reason, + deleted=(user.deleted or user.suspended) or False, # Treat suspended as deleted ) @router.get("/user/id/{user_id}") @@ -234,13 +325,18 @@ async def get_user_by_id( """ Get user profile by user ID """ + if user_id <= 0: + raise HTTPException(status_code=400, detail="Invalid user ID") + user = db.query(User).filter(User.id == user_id).first() if not user: raise HTTPException(status_code=404, detail="User not found") + + _ensure_owner_unsuspended(user, db) - # Handle deleted users - if user.deleted: + # Handle deleted or suspended users + if user.deleted or user.suspended: return UserProfileResponse( id=user.id, username="deleted", @@ -293,6 +389,15 @@ async def verify_user( target_user.verified = not target_user.verified db.commit() + log_security( + "admin_verify_toggle", + actor=current_user.username, + actor_id=current_user.id, + target_username=target_user.username, + target_id=target_user.id, + verified=target_user.verified, + ) + return { "verified": target_user.verified, "message": f"User verification {'enabled' if target_user.verified else 'disabled'}" @@ -363,6 +468,15 @@ async def suspend_user( target_user.suspension_reason = request.reason db.commit() + log_security( + "admin_suspend_user", + actor=current_user.username, + actor_id=current_user.id, + target_username=target_user.username, + target_id=target_user.id, + reason=request.reason, + ) + # Send WebSocket suspension message try: await messagingManager.send_suspension_to_user(user_id, request.reason) @@ -399,6 +513,14 @@ async def unsuspend_user( target_user.suspension_reason = None db.commit() + log_security( + "admin_unsuspend_user", + actor=current_user.username, + actor_id=current_user.id, + target_username=target_user.username, + target_id=target_user.id, + ) + return { "status": "success", "message": f"User {target_user.username} has been unsuspended" @@ -426,9 +548,22 @@ async def delete_user( if target_user.id == 1: raise HTTPException(status_code=400, detail="Cannot delete admin account") + snapshot_username = target_user.username + snapshot_display_name = target_user.display_name + from .account import _delete_user_data await _delete_user_data(target_user, db) + log_security( + "admin_delete_user", + severity="warning", + actor=current_user.username, + actor_id=current_user.id, + target_username=snapshot_username, + target_display_name=snapshot_display_name, + target_id=target_user.id, + ) + return { "status": "success", "message": f"User {target_user.username} has been deleted" diff --git a/backend/security/__init__.py b/backend/security/__init__.py new file mode 100644 index 0000000..9429562 --- /dev/null +++ b/backend/security/__init__.py @@ -0,0 +1,2 @@ +# Package marker for security utilities + diff --git a/backend/security/audit.py b/backend/security/audit.py new file mode 100644 index 0000000..f9e7e64 --- /dev/null +++ b/backend/security/audit.py @@ -0,0 +1,406 @@ +from __future__ import annotations + +import logging +from html import unescape +from typing import Any, Callable, Dict, List + +from logging_config import access_logger, dm_logger, public_chat_logger, security_logger + + +def _clean_username(username: Any) -> str: + if not username: + return "unknown user" + return f"@{username}" + + +def _format_user(fields: Dict[str, Any], username_key: str = "username", user_id_key: str = "user_id") -> str: + username = fields.get(username_key) + if username is None and "_" in username_key: + base_key = username_key.split("_", 1)[0] + username = fields.get(base_key) + + user_id = fields.get(user_id_key) + if user_id is None and "_" in user_id_key: + base_key = user_id_key.split("_", 1)[0] + user_id = fields.get(base_key) + + if username and user_id is not None: + return f"{_clean_username(username)} (user id {user_id})" + if username: + return _clean_username(username) + if user_id is not None: + return f"user id {user_id}" + return "unknown user" + + +def _format_actor(fields: Dict[str, Any], prefix: str) -> str: + return _format_user(fields, f"{prefix}_username", f"{prefix}_id") + + +def _plural(label: str, count: int) -> str: + return f"{count} {label if count == 1 else label + 's'}" + + +def _yes_no(flag: Any) -> str: + return "yes" if flag else "no" + + +def _render_security(action: str, fields: Dict[str, Any]) -> List[str]: + if action == "login_success": + lines = [f"Login approved for {_format_user(fields)}"] + session = fields.get("session_id") + if session: + lines.append(f"Session: {session}") + client_bits: List[str] = [] + if fields.get("device"): + client_bits.append(fields["device"]) + if fields.get("os"): + client_bits.append(fields["os"]) + if fields.get("browser"): + client_bits.append(fields["browser"]) + if client_bits: + lines.append(f"Client: {', '.join(client_bits)}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "login_failed": + lines = [f"Login denied for {_format_user(fields)}"] + if fields.get("reason"): + lines.append(f"Reason: {fields['reason']}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "auth_bruteforce_detected": + lines = ["Brute-force login pattern detected"] + lines.append(f"Target: {_format_user(fields)}") + failures = fields.get("failures") + if isinstance(failures, dict): + for key, value in failures.items(): + lines.append(f"{key}: {value}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + if fields.get("window_seconds"): + lines.append(f"Observation window: {fields['window_seconds']} seconds") + return lines + if action == "registration_success": + ip_raw = fields.get("ip") + ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw + display_name = fields.get("display_name") or "Unknown" + username = fields.get("username") + user_id = fields.get("user_id") + user_agent = fields.get("user_agent") or "Unknown user agent" + lines = ["Account registered"] + lines.append(f"Display name: {display_name}") + lines.append(f"Username: {_clean_username(username) if username else 'unknown'}") + if ip_display: + lines.append(f"IP: {ip_display}") + if user_agent: + lines.append(f"User agent: {user_agent}") + if user_id is not None: + lines.append(f"User ID: {user_id}") + return lines + if action == "password_changed": + lines = [f"Password changed for {_format_user(fields)}"] + lines.append(f"Other sessions revoked: {_yes_no(fields.get('logout_others'))}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "logout": + lines = [f"Logout recorded for {_format_user(fields)}"] + if fields.get("session_id"): + lines.append(f"Session: {fields['session_id']}") + if fields.get("ip"): + lines.append(f"IP address: {fields['ip']}") + return lines + if action == "admin_delete_user": + return [ + "Account removal", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + ] + if action == "admin_suspend_user": + lines = [ + "User suspension", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + ] + if fields.get("reason"): + lines.append(f"Reason: {fields.get('reason')}") + return lines + if action == "admin_unsuspend_user": + return [ + "User unsuspension", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + ] + if action == "admin_verify_toggle": + return [ + "User verification", + f"Actor: {_format_actor(fields, 'actor')}", + f"Target: {_format_actor(fields, 'target')}", + f"Verified: {_yes_no(fields.get('verified'))}", + ] + if action == "self_delete_account": + return [f"User {_format_user(fields)} deleted their account"] + if action == "auto_suspension_public_spam": + lines = [ + f"Automatic suspension triggered for {_format_user(fields)}", + ] + match_type = fields.get("match_type") + if match_type: + lines.append(f"Match type: {match_type}") + similar = fields.get("similar_messages") + occurrences = fields.get("occurrences") + if similar: + lines.append(f"Similar messages detected: {similar}") + if occurrences and not similar: + lines.append(f"Occurrences: {occurrences}") + if fields.get("window_seconds"): + lines.append(f"Observation window: {fields['window_seconds']} seconds") + if fields.get("reason"): + lines.append(f"Reason: {fields['reason']}") + return lines + if action == "auto_suspension_public_burst": + lines = [ + f"Automatic suspension triggered for {_format_user(fields)}", + f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds", + ] + if fields.get("reason"): + lines.append(f"Reason: {fields['reason']}") + return lines + if action == "public_message_burst": + return [ + f"Rapid messaging spike for {_format_user(fields)}", + f"Messages sent: {fields.get('count')} within {fields.get('window_seconds')} seconds", + ] + if action == "blocklist_add": + added = fields.get("added") or [] + lines = [f"Blocklist updated by {_format_actor(fields, 'actor')}"] + if added: + lines.append(f"Added entries: {', '.join(added)}") + total = len(fields.get("words") or []) + lines.append(f"Total entries: {total}") + return lines + if action == "blocklist_remove": + removed = fields.get("removed") or [] + lines = [f"Blocklist cleaned by {_format_actor(fields, 'actor')}"] + if removed: + lines.append(f"Removed entries: {', '.join(removed)}") + total = len(fields.get("words") or []) + lines.append(f"Total entries: {total}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: + if action == "message_created": + lines = [f"Message #{fields.get('message_id')} sent by {_format_user(fields)}"] + if fields.get("reply_to"): + lines.append(f"In reply to message #{fields['reply_to']}") + attachments = fields.get("attachments") + if attachments: + lines.append(f"Attachments: {_plural('file', attachments)}") + + # If content was censored, log both raw and censored versions + if fields.get("raw_content") is not None: + lines.append("Raw content (before censoring):") + for line in unescape(fields["raw_content"]).splitlines(): + lines.append(f"| {line}") + lines.append("Censored content (stored):") + for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines(): + lines.append(f"| {line}") + elif fields.get("content"): + lines.append("Content:") + for line in unescape(fields["content"]).splitlines(): + lines.append(f"| {line}") + return lines + if action == "message_edited": + lines = [f"Message #{fields.get('message_id')} edited by {_format_user(fields)}"] + if fields.get("reply_to"): + lines.append(f"Reply to #{fields['reply_to']}") + if fields.get("previous_content"): + lines.append("Previous content:") + for line in unescape(fields["previous_content"] or "").splitlines() or [""]: + lines.append(f"| {line}") + + # If content was censored, log both raw and censored versions + if fields.get("raw_content") is not None: + lines.append("Raw content (before censoring):") + for line in unescape(fields["raw_content"]).splitlines(): + lines.append(f"| {line}") + lines.append("Censored content (stored):") + for line in unescape(fields.get("censored_content", fields.get("content", ""))).splitlines(): + lines.append(f"| {line}") + elif fields.get("content"): + lines.append("New content:") + for line in unescape(fields["content"] or "").splitlines() or [""]: + lines.append(f"| {line}") + + return lines + if action == "message_deleted": + lines = [ + f"Message #{fields.get('message_id')} deleted", + f"Actor: {_format_actor(fields, 'actor')}", + ] + if fields.get("original_author_id") is not None: + lines.append(f"Original author: user #{fields['original_author_id']}") + if fields.get("content"): + lines.append("Previous content:") + for line in unescape(fields["content"]).splitlines(): + lines.append(f"| {line}") + return lines + if action == "reaction_update": + lines = [ + f"Reaction {fields.get('action', 'updated')} on message #{fields.get('message_id')}", + f"User: {_format_user(fields)}", + ] + if fields.get("emoji"): + lines.append(f"Emoji: {fields['emoji']}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _render_dm(action: str, fields: Dict[str, Any]) -> List[str]: + if action in {"message_sent", "message_sent_ws"}: + lines = [ + f"Direct message #{fields.get('dm_envelope_id')} sent", + f"Sender: {_format_actor(fields, 'sender')}", + ] + if fields.get("recipient_id") is not None: + lines.append(f"Recipient: user id {fields['recipient_id']}") + attachments = fields.get("attachment_count") + if attachments: + lines.append(f"Attachments: {_plural('file', attachments)}") + if fields.get("reply_to"): + lines.append(f"In reply to DM #{fields['reply_to']}") + return lines + if action == "message_edited": + return [ + f"Direct message #{fields.get('dm_envelope_id')} edited", + f"Author: {_format_user(fields)}", + ] + if action == "message_deleted": + lines = [ + f"Direct message #{fields.get('dm_envelope_id')} deleted", + f"Actor: {_format_user(fields)}", + ] + if fields.get("recipient_id") is not None: + lines.append(f"Recipient: user id {fields['recipient_id']}") + return lines + if action == "reaction_update": + lines = [ + f"Reaction {fields.get('action', 'updated')} on DM #{fields.get('dm_envelope_id')}", + f"User: {_format_user(fields)}", + ] + if fields.get("emoji"): + lines.append(f"Emoji: {fields['emoji']}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _render_access(action: str, fields: Dict[str, Any]) -> List[str]: + ip_raw = fields.get("ip") + ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw + if action == "http_request": + first_line = f"{fields.get('method')} {fields.get('path')}" + if ip_display: + first_line += f" from {ip_display}" + first_line += f" -> {fields.get('status')}" + lines = [first_line] + if fields.get("user"): + lines.append(f"Authenticated user: {_clean_username(fields['user'])}") + return lines + if action == "http_error": + first_line = f"HTTP error during {fields.get('method')} {fields.get('path')}" + if ip_display: + first_line += f" from {ip_display}" + lines = [first_line] + if fields.get("error"): + lines.append(f"Exception: {fields['error']}") + if fields.get("user"): + lines.append(f"Authenticated user: {_clean_username(fields['user'])}") + return lines + if action == "ws_connect": + lines = ["WebSocket connected"] + if fields.get("path"): + lines.append(f"Endpoint: {fields['path']}") + if ip_display: + lines.append(f"IP: {ip_display}") + return lines + if action == "ws_disconnect": + lines = ["WebSocket disconnected"] + if fields.get("path"): + lines.append(f"Endpoint: {fields['path']}") + if fields.get("code") is not None: + reason = fields.get("reason") or "no reason" + lines.append(f"Code {fields['code']} ({reason})") + if ip_display: + lines.append(f"IP: {ip_display}") + return lines + if action == "ws_event": + event_name = fields.get("event") + path = fields.get("path") + first_line = "WS" + if path: + first_line += f" {path}" + if ip_display: + first_line += f" from {ip_display}" + if event_name: + first_line += f" -> {event_name}" + lines = [first_line] + if fields.get("user"): + lines.append(f"Authenticated user: {_format_user(fields, 'user', 'user_id')}") + for key, value in fields.items(): + if key in {"path", "event", "user", "user_id", "ip"} or value is None: + continue + lines.append(f"{key.replace('_', ' ').capitalize()}: {value}") + return lines + return [f"{action.replace('_', ' ').capitalize()}"] + [ + f"{key.replace('_', ' ').capitalize()}: {value}" + for key, value in fields.items() + if value is not None + ] + + +def _log_event( + logger: logging.Logger, + renderer: Callable[[str, Dict[str, Any]], List[str]], + action: str, + severity: str, + fields: Dict[str, Any], +) -> None: + lines = renderer(action, fields) + if not lines: + return + level = getattr(logging, severity.upper(), logging.INFO) + logger.log(level, "\n".join(lines)) + + +def log_security(action: str, severity: str = "info", **fields: Any) -> None: + _log_event(security_logger, _render_security, action, severity, fields) + + +def log_public_chat(action: str, severity: str = "info", **fields: Any) -> None: + _log_event(public_chat_logger, _render_public_chat, action, severity, fields) + + +def log_dm(action: str, severity: str = "info", **fields: Any) -> None: + sanitized_fields = {key: value for key, value in fields.items() if key != "content"} + _log_event(dm_logger, _render_dm, action, severity, sanitized_fields) + + +def log_access(action: str, severity: str = "info", **fields: Any) -> None: + _log_event(access_logger, _render_access, action, severity, fields) + diff --git a/backend/security/profanity.py b/backend/security/profanity.py new file mode 100644 index 0000000..7091df4 --- /dev/null +++ b/backend/security/profanity.py @@ -0,0 +1,694 @@ +from __future__ import annotations + +import json +import re +import unicodedata +from pathlib import Path +from threading import RLock +from typing import Iterable, List, Set, Tuple + +from better_profanity import Profanity + +BLOCKLIST_PATH = Path("data/profanity/blocklist.json") +BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) + +_CUSTOM_RU_TERMS: Set[str] = { + "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", + "ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда", + "пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", + "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", + "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор", + "пидоры", "пидорас", "пидорасы", "пидорасов", +} + +_ADULT_TERMS: Set[str] = { + "порно", "порнуха", "эротика", "эротический", "секс", "сексуальный", + "инцест", "порнография", "порностудия", "порновидео", "порносайт", + "сексчат", "сексчатик", "секслайв", "сексвидео", +} + +_STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS)) + +# Words that should never be flagged as profanity (whitelist) +_WHITELIST: Set[str] = { + "говно", # Allow this word +} + +# Phrase patterns - these will be applied to normalized text (without special chars) +_PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( + re.compile(r"\bmax\s+is\s+better\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bмакс\s+лучше\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bfromchat\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bфромчат\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\b18\+\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bxxx\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bайфон\s+топ\b", re.IGNORECASE | re.UNICODE), + re.compile(r"\bсамсунг\s+г[ао]вно\b", re.IGNORECASE | re.UNICODE), +) + +# Patterns to check in original text (before normalization) to catch visual bypasses +# These patterns check for special character combinations that visually form letters +_ORIGINAL_TEXT_PATTERNS: Tuple[re.Pattern[str], ...] = ( + # Catch "}{" used to visually form "х" followed by "С0С" or similar patterns + # This catches "хуесос" written as "}{¥€С0С" or variations + # Matches: }{ + any characters (including special chars) + С/с + 0 + С/с + # The pattern allows any characters between to catch special chars like ¥€ + re.compile(r"}\{.*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE), + # Also catch "}{" followed by "уесос" with 0 instead of о + re.compile(r"}\{.*?[уyУY].*?[еeЕE].*?[сcСC].*?[0оoОO].*?[сcСC]", re.IGNORECASE | re.UNICODE), +) + +# Map for normalizing homoglyphs (similar-looking characters) +# Maps English/Latin characters to their Cyrillic equivalents and vice versa +# Also includes Greek, full-width, and other Unicode variants +_LEET_MAP = { + # Numbers to letters + "0": "о", + "1": "и", + "3": "е", + "4": "а", + # Latin to Cyrillic (lowercase) + "a": "а", + "c": "с", + "e": "е", + "f": "ф", + "g": "г", + "i": "и", + "m": "м", + "n": "н", + "o": "о", + "p": "п", + "s": "с", + "t": "т", + "u": "у", + "v": "в", + "x": "х", + "y": "у", + "z": "з", # English 'z' to Cyrillic 'з' + # Latin to Cyrillic (uppercase) + "A": "а", + "C": "с", + "E": "е", + "F": "ф", + "G": "г", + "I": "и", + "M": "м", + "N": "н", + "O": "о", + "P": "п", + "S": "с", + "T": "т", + "U": "у", + "V": "в", + "X": "х", + "Y": "у", + "Z": "з", # English 'Z' to Cyrillic 'з' + # Greek letters that look like Cyrillic/Latin + "α": "а", # Greek alpha + "Α": "а", + "ο": "о", # Greek omicron + "Ο": "о", + "ρ": "р", # Greek rho (looks like Cyrillic р) + "Ρ": "р", + "υ": "у", # Greek upsilon + "Υ": "у", + "χ": "х", # Greek chi + "Χ": "х", + "ε": "е", # Greek epsilon + "Ε": "е", + "ι": "и", # Greek iota + "Ι": "и", + "ν": "н", # Greek nu + "Ν": "н", + "μ": "м", # Greek mu + "Μ": "м", + "π": "п", # Greek pi + "Π": "п", + "τ": "т", # Greek tau + "Τ": "т", + "γ": "г", # Greek gamma + "Γ": "г", + "σ": "с", # Greek sigma + "Σ": "с", + "φ": "ф", # Greek phi + "Φ": "ф", + # Full-width Latin characters + "a": "а", + "A": "а", + "c": "с", + "C": "с", + "e": "е", + "E": "е", + "f": "ф", + "F": "ф", + "g": "г", + "G": "г", + "i": "и", + "I": "и", + "m": "м", + "M": "м", + "n": "н", + "N": "н", + "o": "о", + "O": "о", + "p": "п", + "P": "п", + "s": "с", + "S": "с", + "t": "т", + "T": "т", + "u": "у", + "U": "у", + "v": "в", + "V": "в", + "x": "х", + "X": "х", + "y": "у", + "Y": "у", + "z": "з", # Full-width 'z' to Cyrillic 'з' + "Z": "з", + # Cyrillic to canonical Cyrillic (identity mappings) + "а": "а", + "с": "с", + "е": "е", + "ё": "е", + "ф": "ф", + "г": "г", + "и": "и", + "м": "м", + "н": "н", + "о": "о", + "п": "п", + "т": "т", + "у": "у", + "ү": "у", # Cyrillic capital U (U+04AE) + "Ү": "у", # Cyrillic capital U (U+04AE) + "в": "в", + "х": "х", + "р": "р", + "з": "з", # Cyrillic 'з' + "д": "д", # Cyrillic 'д' + "б": "б", # Cyrillic 'б' + "л": "л", # Cyrillic 'л' + "я": "я", # Cyrillic 'я' + "н": "н", # Already mapped, but explicit + # Special characters + "@": "а", + # Multi-character visual bypasses (handled separately in preprocessing) + # "}{" visually forms "х" - handled in _preprocess_visual_bypasses +} + +_RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( + ("generic", ("айфон", "топ")), + ("generic", ("самсунг", "говно")), +) + +_SENSITIVE_PHRASE_PATH = Path("data/profanity/sensitive_phrases.json") +_PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {} + + +def _preprocess_visual_bypasses(text: str) -> str: + """ + Preprocess text to convert multi-character visual bypasses to their intended letters. + This handles cases like "}{" visually forming "х". + """ + result = text + # Convert "}{" to "х" (visual bypass for Cyrillic х) + # The curly braces visually form the letter х when placed together + result = result.replace("}{", "х") + return result + + +def _normalize_char(ch: str) -> str: + """Normalize a single character, mapping homoglyphs to canonical form.""" + # First try direct mapping (preserves case for non-mapped chars) + if ch in _LEET_MAP: + return _LEET_MAP[ch] + # Then try lowercase mapping + lower = ch.lower() + if lower in _LEET_MAP: + return _LEET_MAP[lower] + # If no mapping and character is ASCII letter, return lowercase + # This preserves English words like "fromchat" as-is + if ch.isascii() and ch.isalpha(): + return lower + # For other characters, return lowercase for consistency + return lower + + +def _normalize_token(token: str) -> str: + """Normalize a token by mapping all homoglyphs.""" + return "".join(_normalize_char(ch) for ch in token) + + +def _normalize_text_for_profanity(text: str) -> str: + """ + Normalize entire text by mapping homoglyphs to canonical forms. + This prevents bypasses like using English 'u' instead of Russian 'у'. + """ + return "".join(_normalize_char(ch) for ch in text) + + +def _strip_zero_width_chars(text: str) -> str: + """ + Remove zero-width characters that could be used to bypass filters. + """ + # Zero-width space, zero-width non-joiner, zero-width joiner, etc. + zero_width_chars = [ + '\u200B', # Zero-width space + '\u200C', # Zero-width non-joiner + '\u200D', # Zero-width joiner + '\uFEFF', # Zero-width no-break space + '\u2060', # Word joiner + '\u2061', # Function application + '\u2062', # Invisible times + '\u2063', # Invisible separator + '\u2064', # Invisible plus + ] + result = text + for zw_char in zero_width_chars: + result = result.replace(zw_char, '') + return result + + +def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) -> tuple[str, list[int]]: + """ + Extract only alphanumeric characters from text and create a mapping + from normalized positions to original positions. + + Args: + preserve_spaces: If True, preserve spaces in the normalized text (for phrase matching) + + Returns: + (normalized_text, position_map) where position_map[i] is the original + position of the i-th character in normalized_text + """ + # First preprocess visual bypasses (like "}{" -> "х") + text = _preprocess_visual_bypasses(text) + + # Then normalize Unicode (composed vs decomposed) + normalized_unicode = unicodedata.normalize('NFKC', text) + + # For phrase matching, convert zero-width chars to spaces instead of stripping + if preserve_spaces: + zero_width_chars = ['\u200B', '\u200C', '\u200D', '\uFEFF', '\u2060', '\u2061', '\u2062', '\u2063', '\u2064'] + for zw_char in zero_width_chars: + normalized_unicode = normalized_unicode.replace(zw_char, ' ') + else: + # Strip zero-width characters + normalized_unicode = _strip_zero_width_chars(normalized_unicode) + + normalized = [] + position_map = [] + + for i, ch in enumerate(normalized_unicode): + # Check if character is alphanumeric (including Cyrillic) + if ch.isalnum(): + # For phrase matching, preserve ASCII letters as-is (just lowercase) + # to allow English words in patterns to match + if preserve_spaces and ch.isascii() and ch.isalpha(): + normalized.append(ch.lower()) + else: + # Normalize this character (homoglyphs, Cyrillic, etc.) + normalized.append(_normalize_char(ch)) + position_map.append(i) + elif preserve_spaces: + # For phrase matching, treat any whitespace or non-alphanumeric as word separator + if ch.isspace() or not ch.isalnum(): + # Normalize to single space to allow patterns to match + if normalized and normalized[-1] != ' ': # Don't add consecutive spaces + normalized.append(' ') + position_map.append(i) + + return "".join(normalized), position_map + + +def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) -> list[tuple[int, int]]: + """ + Check for profane words as substrings or subsequences in normalized text. + This catches cases like "хуй" in "хууй" (with extra characters). + Returns list of (start, end) positions where profanity is found. + """ + spans = [] + normalized_lower = normalized_text.lower() + + for word in profane_words: + word_lower = word.lower() + + # First try exact substring match + start = 0 + while True: + pos = normalized_lower.find(word_lower, start) + if pos == -1: + break + spans.append((pos, pos + len(word_lower))) + start = pos + 1 + + # Also check if profane word appears as a subsequence (allowing extra chars) + # This catches cases like "хуй" in "хууй" or "х}{¥€уй" -> "хууй" + # Now applies to ALL words, not just length >= 4, to prevent bypasses + word_chars = list(word_lower) + text_chars = list(normalized_lower) + + # Stricter span limits based on word length to prevent false positives + # Shorter words get much stricter limits + if len(word_lower) <= 3: + max_span_ratio = 1.3 # Very strict for 3-char words (e.g., "хуй") + elif len(word_lower) == 4: + max_span_ratio = 1.4 # Strict for 4-char words + elif len(word_lower) <= 5: + max_span_ratio = 1.5 # Moderate for 5-char words + else: + max_span_ratio = 1.8 # Slightly more lenient for longer words + + # Try to find the word as a subsequence + i = 0 # position in text + j = 0 # position in word + seq_start = None + + while i < len(text_chars) and j < len(word_chars): + if text_chars[i] == word_chars[j]: + if seq_start is None: + seq_start = i + j += 1 + if j == len(word_chars): + # Found the word as subsequence + seq_end = i + 1 + # Check if the span is reasonable (not too long) + span_length = seq_end - seq_start + max_allowed_span = int(len(word_lower) * max_span_ratio) + if span_length <= max_allowed_span: + # Only add if it's not already covered by exact match + if (seq_start, seq_end) not in spans: + spans.append((seq_start, seq_end)) + # Reset to find next occurrence - continue from after the end of this match + next_start = seq_start + 1 + seq_start = None + j = 0 + i = next_start + continue + i += 1 + + return spans + + +def _check_profanity_in_normalized(normalized_text: str) -> bool: + """ + Check if normalized text contains profanity. + Uses both better_profanity library and substring matching for better detection. + + Returns True if profanity is found. + """ + if not normalized_text: + return False + + # Check normalized text for profanity using better_profanity + censored = _profanity.censor(normalized_text, censor_char="\\*") + + # Check if better_profanity found anything + if "*" in censored: + return True + + # Also check for profane words as substrings (to catch cases like "хуй" in "хууй" or "хуйня") + profane_words = _STATIC_TERMS + substring_spans = _check_profanity_substrings(normalized_text, profane_words) + + # If we found any substring matches, there's profanity + if substring_spans: + return True + + return False + + +def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]: + tokens: List[Tuple[int, int, str]] = [] + start: int | None = None + buffer: List[str] = [] + + for idx, ch in enumerate(text): + if ch.isalnum() or ch in {"@", "#", "_"}: + if start is None: + start = idx + buffer.append(ch) + else: + if buffer and start is not None: + token_raw = "".join(buffer) + tokens.append((start, idx, _normalize_token(token_raw))) + buffer.clear() + start = None + if buffer and start is not None: + token_raw = "".join(buffer) + tokens.append((start, len(text), _normalize_token(token_raw))) + return tokens + + +def _edit_distance_limited(a: str, b: str, max_distance: int = 1) -> bool: + if a == b: + return True + if max_distance <= 0: + return False + if abs(len(a) - len(b)) > max_distance: + return False + + previous = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + current = [i] + best = current[0] + for j, cb in enumerate(b, 1): + insert_cost = current[j - 1] + 1 + delete_cost = previous[j] + 1 + replace_cost = previous[j - 1] + (0 if ca == cb else 1) + cost = min(insert_cost, delete_cost, replace_cost) + current.append(cost) + if cost < best: + best = cost + if best > max_distance: + return False + previous = current + return previous[-1] <= max_distance + + +def _load_sensitive_phrases() -> List[Tuple[str, ...]]: + if not _SENSITIVE_PHRASE_PATH.exists(): + return [] + try: + payload = json.loads(_SENSITIVE_PHRASE_PATH.read_text(encoding="utf-8")) + phrases: List[Tuple[str, ...]] = [] + if isinstance(payload, list): + for entry in payload: + if isinstance(entry, list) and entry: + normalized = tuple(str(part).strip() for part in entry if str(part).strip()) + if normalized: + phrases.append(normalized) + return phrases + except Exception: + return [] + + +def _get_phrases(group: str) -> Tuple[Tuple[str, ...], ...]: + if group not in _PHRASE_CACHE: + base = [phrase for key, phrase in _RAW_PHRASE_GROUPS if key == group] + if group == "sensitive": + base.extend(_load_sensitive_phrases()) + _PHRASE_CACHE[group] = tuple( + tuple(_normalize_token(part) for part in phrase) + for phrase in base + ) + return _PHRASE_CACHE[group] + + +def _find_fuzzy_phrase_spans(text: str, group: str = "generic") -> List[Tuple[int, int]]: + tokens = _tokenize_with_spans(text) + if not tokens: + return [] + + spans: List[Tuple[int, int]] = [] + normalized_phrases = _get_phrases(group) + + for index in range(len(tokens)): + for phrase in normalized_phrases: + if index + len(phrase) > len(tokens): + continue + matches = True + for offset, target in enumerate(phrase): + token = tokens[index + offset][2] + if not _edit_distance_limited(token, target): + matches = False + break + if matches: + span_start = tokens[index][0] + span_end = tokens[index + len(phrase) - 1][1] + spans.append((span_start, span_end)) + return spans + +_dictionary_lock = RLock() +_blocklist_signature: Tuple[str, ...] | None = None +_profanity = Profanity() + + +def _normalize_words(words: Iterable[str]) -> Set[str]: + normalized: Set[str] = set() + for raw in words: + if not raw: + continue + cleaned = re.sub(r"\s+", " ", str(raw)).strip().lower() + if cleaned: + normalized.add(cleaned) + return normalized + + +def _load_blocklist() -> Set[str]: + if not BLOCKLIST_PATH.exists(): + return set() + try: + data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) + if isinstance(data, list): + return _normalize_words(data) + except Exception: + pass + return set() + + +def _write_blocklist(words: Iterable[str]) -> None: + BLOCKLIST_PATH.write_text( + json.dumps(sorted(words), ensure_ascii=False, indent=2) + "\n", + encoding="utf-8" + ) + + +def _rebuild_dictionary(force: bool = False) -> None: + global _profanity, _blocklist_signature + with _dictionary_lock: + blocklist_list = sorted(_load_blocklist()) + signature = tuple(blocklist_list) + if not force and _blocklist_signature == signature and _blocklist_signature is not None: + return + + profanity = Profanity() + profanity.load_censor_words() + # Remove whitelisted words from the default word list + try: + for word in _WHITELIST: + profanity.remove_censor_words([word]) + except AttributeError: + # If remove_censor_words doesn't exist, we'll handle it in post-processing + pass + combined = set(_STATIC_TERMS) + combined.update(blocklist_list) + # Remove whitelisted words from our custom terms + combined -= _WHITELIST + if combined: + profanity.add_censor_words(list(combined)) + + _profanity = profanity + _blocklist_signature = signature + + +def _check_phrase_patterns(text: str) -> bool: + """ + Check if text matches any phrase patterns. + Returns True if any pattern matches. + """ + # Normalize text for phrase matching (remove special chars but preserve spaces) + normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True) + normalized_lower = normalized_text.lower() + + # Check phrase patterns + for pattern in _PHRASE_PATTERNS: + if pattern.search(normalized_lower): + return True + + # Check fuzzy phrase spans + if _find_fuzzy_phrase_spans(normalized_lower, "generic"): + return True + + return False + + +def contains_profanity(text: str) -> bool: + """ + Check if text contains profanity. + Returns True if profanity is detected. + """ + if not text: + return False + + _rebuild_dictionary() + + # Check original text patterns first (before normalization) to catch visual bypasses + # like "}{" used to form "х" + for pattern in _ORIGINAL_TEXT_PATTERNS: + if pattern.search(text): + return True + + # Check phrase patterns + if _check_phrase_patterns(text): + return True + + # Normalize text for whitelist matching (to handle special characters) + normalized_for_whitelist, _ = _extract_alphanumeric_with_mapping(text) + normalized_for_whitelist_lower = normalized_for_whitelist.lower() + + # Check if text contains whitelisted words - if the entire text is a whitelisted word, skip profanity check + for whitelist_word in _WHITELIST: + normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) + normalized_whitelist_lower = normalized_whitelist.lower() + + # Check if the normalized text exactly matches a whitelisted word + if normalized_for_whitelist_lower == normalized_whitelist_lower: + return False + + # Extract only alphanumeric characters and normalize homoglyphs + # This removes special characters, emojis, etc. that could be used to bypass the filter + normalized_text, _ = _extract_alphanumeric_with_mapping(text) + + # Check profanity on normalized text (without special characters) + return _check_profanity_in_normalized(normalized_text) + + +def contains_sensitive_phrase(text: str) -> bool: + if not text: + return False + if _find_fuzzy_phrase_spans(text, "sensitive"): + return True + return False + + +def get_blocklist() -> List[str]: + with _dictionary_lock: + return sorted(_load_blocklist()) + + +def add_to_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]: + normalized = _normalize_words(words) + if not normalized: + return [], get_blocklist() + + with _dictionary_lock: + current = _load_blocklist() + added = sorted(normalized - current) + if not added: + return [], sorted(current) + + updated = sorted(current | normalized) + _write_blocklist(updated) + _rebuild_dictionary(force=True) + return added, updated + + +def remove_from_blocklist(words: Iterable[str]) -> Tuple[List[str], List[str]]: + normalized = _normalize_words(words) + if not normalized: + return [], get_blocklist() + + with _dictionary_lock: + current = _load_blocklist() + removed = sorted(word for word in normalized if word in current) + if not removed: + return [], sorted(current) + + updated = sorted(current - normalized) + _write_blocklist(updated) + _rebuild_dictionary(force=True) + return removed, updated + diff --git a/backend/security/rate_limit.py b/backend/security/rate_limit.py new file mode 100644 index 0000000..2e94d23 --- /dev/null +++ b/backend/security/rate_limit.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import asyncio +import logging +import time +from typing import Callable +from fastapi import Request +from slowapi import Limiter +from slowapi.util import get_remote_address + +from utils import get_client_ip + +logger = logging.getLogger("uvicorn.error") + +def get_ip_key(request: Request) -> str: + """Get rate limit key based on IP address.""" + return get_client_ip(request) or get_remote_address(request) + +# Initialize limiter with IP-based key function +# Note: We don't set default_limits to avoid affecting all users if one IP is attacked. +# Each endpoint should have an explicit rate limit based on its sensitivity. +# Rate limits automatically expire after the time window - IPs are not permanently blocked. +limiter = Limiter( + key_func=get_ip_key, + default_limits=[], # No global default - each endpoint must have explicit limits + storage_uri="memory://", # In-memory storage (can be changed to Redis later) +) + + +# Rate limit decorator for IP-based limiting +def rate_limit_per_ip(limit: str) -> Callable: + """Rate limit based on IP address.""" + return limiter.limit(limit, key_func=get_ip_key) + + +def _get_storage_dict(storage) -> dict | None: + """Get the internal storage dictionary from slowapi's memory storage.""" + if hasattr(storage, "_storage") and isinstance(storage._storage, dict): + return storage._storage + elif hasattr(storage, "storage") and isinstance(storage.storage, dict): + return storage.storage + return None + + +def reset_all_rate_limits() -> int: + """ + Reset all rate limits by clearing the storage. + This should be called on startup to ensure a clean state. + Returns the number of entries cleared. + """ + try: + # Access the private _storage attribute + storage = limiter._storage + storage_dict = _get_storage_dict(storage) + + if storage_dict is None: + # Try using the storage's reset method if available + if hasattr(storage, "reset"): + try: + # Try reset() with no args first (clears all) + storage.reset() + logger.info("Reset all rate limits on startup using storage.reset()") + return 1 # Assume it worked + except TypeError: + # reset() might require arguments, try clearing differently + try: + # Some storage backends need explicit clearing + if hasattr(storage, "clear"): + storage.clear() + logger.info("Reset all rate limits on startup using storage.clear()") + return 1 + except Exception: + pass + except Exception: + pass + logger.warning("Could not reset rate limits: storage dict not accessible and no reset method") + return 0 + + count = len(storage_dict) + if count > 0: + storage_dict.clear() + logger.info(f"Reset all rate limits on startup: cleared {count} entries") + return count + except Exception as e: + logger.warning(f"Failed to reset rate limits on startup: {e}") + return 0 + + +def reset_rate_limit_for_ip(ip: str) -> bool: + """ + Manually reset rate limit for a specific IP address. + This clears all rate limit entries for the given IP. + Returns True if any entries were cleared, False otherwise. + """ + if not ip: + return False + + try: + # Access the private _storage attribute + storage = limiter._storage + storage_dict = _get_storage_dict(storage) + + if storage_dict is None: + # Try alternative methods + if hasattr(storage, "reset"): + try: + storage.reset(ip) + return True + except Exception: + pass + return False + + cleared = False + # slowapi stores entries with keys like "LIMITER:{ip}:{endpoint}" + # We need to find all keys that contain this IP + # Also handle cases where IP might be in different positions + keys_to_remove = [] + + for key in list(storage_dict.keys()): + if isinstance(key, str): + # Check multiple patterns: + # - "LIMITER:{ip}:{endpoint}" + # - Keys containing the IP anywhere + # - Keys starting with the IP + if (key.startswith(f"LIMITER:{ip}:") or + key.startswith(f"LIMITER:{ip}") or + f":{ip}:" in key or + key.endswith(f":{ip}") or + (ip in key and "LIMITER" in key)): + keys_to_remove.append(key) + + for key in keys_to_remove: + try: + del storage_dict[key] + cleared = True + logger.info(f"Cleared rate limit key: {key}") + except KeyError: + pass + + if cleared: + logger.info(f"Successfully cleared rate limits for IP: {ip}") + else: + logger.warning(f"No rate limit entries found for IP: {ip}") + + return cleared + except Exception as e: + logger.warning(f"Failed to reset rate limit for IP {ip}: {e}") + return False + + +def clear_all_rate_limits() -> int: + """ + Clear all rate limit entries. Use with caution - this affects all IPs. + Returns the number of entries cleared. + """ + try: + # Access the private _storage attribute + storage = limiter._storage + storage_dict = _get_storage_dict(storage) + + if storage_dict is None: + return 0 + + count = len(storage_dict) + storage_dict.clear() + logger.warning(f"Cleared all {count} rate limit entries") + return count + except Exception as e: + logger.error(f"Failed to clear all rate limits: {e}") + return 0 + + +def cleanup_expired_rate_limits() -> int: + """ + Clean up expired rate limit entries from memory storage. + This helps prevent rate limits from being stuck indefinitely. + Returns the number of entries cleaned up. + """ + try: + # Access the private _storage attribute + storage = limiter._storage + storage_dict = _get_storage_dict(storage) + + if storage_dict is None: + return 0 + + # slowapi's memory storage stores entries as tuples: (count, reset_time) + # Entries should expire naturally, but we'll clean up any that are clearly expired + now = time.time() + cleaned = 0 + keys_to_remove = [] + + for key, value in storage_dict.items(): + if isinstance(value, (tuple, list)) and len(value) >= 2: + # Check if reset_time has passed (with some buffer) + reset_time = value[1] if isinstance(value[1], (int, float)) else 0 + # Add 60 second buffer to ensure we don't remove active entries + if reset_time > 0 and now > (reset_time + 60): + keys_to_remove.append(key) + elif isinstance(value, dict): + # Some storage formats use dicts with 'expiry' or 'reset' fields + expiry = value.get("expiry") or value.get("reset") or value.get("reset_time") + if expiry and isinstance(expiry, (int, float)) and now > (expiry + 60): + keys_to_remove.append(key) + + for key in keys_to_remove: + try: + del storage_dict[key] + cleaned += 1 + except KeyError: + pass + + if cleaned > 0: + logger.info(f"Cleaned up {cleaned} expired rate limit entries") + + return cleaned + except Exception as e: + logger.warning(f"Failed to cleanup expired rate limits: {e}") + return 0 + + +async def start_rate_limit_cleanup_task() -> None: + """Start a background task to periodically clean up expired rate limit entries.""" + while True: + try: + await asyncio.sleep(300) # Run every 5 minutes + cleanup_expired_rate_limits() + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"Error in rate limit cleanup task: {e}") + await asyncio.sleep(60) # Wait 1 minute before retrying \ No newline at end of file diff --git a/backend/utils.py b/backend/utils.py index 09db130..ac2cd5a 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -1,18 +1,20 @@ from datetime import datetime, timedelta +from fastapi import Request import jwt -from typing import Optional +from typing import Optional, Any import bcrypt -from constants import * +from constants import MAX_TOKEN_LIFETIME_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM # JWT Helper Functions def create_token(user_id: int, username: str, session_id: str) -> str: - expire = datetime.now() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + # Set a long expiration as safety net (actual expiration based on inactivity) + expire = datetime.now() + timedelta(hours=MAX_TOKEN_LIFETIME_HOURS) payload = { "user_id": user_id, "username": username, "session_id": session_id, - "exp": expire + "exp": int(expire.timestamp()) # JWT exp must be Unix timestamp (int) } return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM) @@ -31,4 +33,39 @@ def verify_password(plain_password: str, hashed_password: str) -> bool: return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password.encode("utf-8")) def get_password_hash(password: str) -> str: - return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") \ No newline at end of file + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def get_client_ip(request: Request) -> Optional[str]: + if not request: + return None + + headers = request.headers + + # First, check x-real-ip header (set by some proxies, or configured in Caddy) + real_ip = headers.get("x-real-ip") or headers.get("X-Real-IP") + if real_ip: + candidate = real_ip.strip() + if candidate: + return candidate + + # Fall back to x-forwarded-for header (Caddy sets this automatically) + forwarded = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") + if forwarded: + # X-Forwarded-For can contain multiple IPs: "client, proxy1, proxy2" + # Take the first one (original client IP) + candidate = forwarded.split(",")[0].strip() + if candidate: + return candidate + + # Fall back to direct client connection (when not behind a proxy) + if request.client and request.client.host: + return request.client.host + + # Last resort: check scope + if isinstance(request.scope, dict): + client_info = request.scope.get("client") + if isinstance(client_info, (list, tuple)) and client_info: + return client_info[0] + + return None \ No newline at end of file diff --git a/backend/websocket/__init__.py b/backend/websocket/__init__.py new file mode 100644 index 0000000..efe848a --- /dev/null +++ b/backend/websocket/__init__.py @@ -0,0 +1,7 @@ +from websocket.registry import WebSocketHandlerRegistry + +# Note: handler_registry and websocket_handler are not imported here to avoid circular dependency +# Import them directly from websocket.handlers when needed + +__all__ = ["WebSocketHandlerRegistry"] + diff --git a/backend/websocket/handlers.py b/backend/websocket/handlers.py new file mode 100644 index 0000000..aa33fc0 --- /dev/null +++ b/backend/websocket/handlers.py @@ -0,0 +1,570 @@ +from datetime import datetime +import json +import logging +import time +from typing import Any +from fastapi import HTTPException, WebSocket, Request +from sqlalchemy.orm import Session + +from websocket.registry import WebSocketHandlerRegistry +from routes.messaging import ( + MessaggingSocketManager, + _send_message_internal, + _edit_message_internal, + get_messages, + edit_message, + delete_message, + add_reaction, + add_dm_reaction, +) +from models import ( + User, + SendMessageRequest, + EditMessageRequest, + DMEnvelope, + ReactionRequest, + DMReactionRequest, + UpdateLog, +) +from security.audit import log_access, log_dm + +logger = logging.getLogger("uvicorn.error") + +# Create global registry instance +handler_registry = WebSocketHandlerRegistry() + +# Create decorator alias +websocket_handler = handler_registry.register + + +def log(manager: MessaggingSocketManager, websocket: WebSocket, user: User | None, event: str, **extra: Any) -> None: + """Log WebSocket event.""" + ws_path = getattr(getattr(websocket, "url", None), "path", None) + if not ws_path and isinstance(getattr(websocket, "scope", None), dict): + ws_path = websocket.scope.get("path") + ws_path = ws_path or "unknown" + headers = {} + if isinstance(getattr(websocket, "scope", None), dict): + headers = {k.decode("latin1"): v.decode("latin1") for k, v in websocket.scope.get("headers", [])} + xff = headers.get("x-forwarded-for") + client_ip = xff.split(",")[0].strip() if xff else (websocket.client.host if websocket.client else None) + + log_access( + "ws_event", + path=ws_path, + event=event, + user=user.username if user else None, + user_id=user.id if user else None, + ip=client_ip, + **extra, + ) + + +@websocket_handler("getUpdates", authRequired=True) +async def getUpdates(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Handle gap detection - client requests updates from a specific sequence number.""" + last_seq = data.get("lastSeq", 0) + manager.last_seq_by_ws[websocket] = last_seq + current_seq = manager.sequence_numbers.get(user.id, 0) + + # Query database for missed updates + missed_updates = [] + if last_seq > 0 and last_seq < current_seq: + try: + # Get all updates between last_seq and current_seq + update_logs = db.query(UpdateLog).filter( + UpdateLog.user_id == user.id, + UpdateLog.sequence > last_seq, + UpdateLog.sequence <= current_seq + ).order_by(UpdateLog.sequence.asc()).all() + + # Each log entry contains a batch of updates with the same sequence number + for log_entry in update_logs: + updates = json.loads(log_entry.updates) + missed_updates.append({ + "seq": log_entry.sequence, + "updates": updates + }) + except Exception as e: + logger.error(f"Failed to retrieve missed updates: {e}") + + # Send missed updates directly (not through return value) + for batch in missed_updates: + await websocket.send_json({ + "type": "updates", + "seq": batch["seq"], + "updates": batch["updates"] + }) + + # Update the websocket's last sequence tracking + manager.last_seq_by_ws[websocket] = current_seq + log(manager, websocket, user, "getUpdates", last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates)) + + return { + "status": "ok", + "lastSeq": current_seq, + "missedCount": len(missed_updates) + } + + +@websocket_handler("ping", authRequired=True) +async def ping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Handle ping - authenticate and set user online.""" + # Set user online in DB + user.online = True + user.last_seen = datetime.now() + db.commit() + # Add to online users + manager.online_users.add(user.id) + # Broadcast status change + await manager.broadcast_status_change(user.id, True, user.last_seen.isoformat(), db) + + log(manager, websocket, user, "ping") + return {"status": "success"} + + +@websocket_handler("getMessages", authRequired=True) +async def getMessages(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Get all public chat messages.""" + result = await get_messages(user, db) + log(manager, websocket, user, "getMessages") + return result + + +@websocket_handler("sendMessage", authRequired=True) +async def sendMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Send a public chat message.""" + message_request: SendMessageRequest = SendMessageRequest.model_validate(data) + + # Call internal function directly (rate limiting is handled at infrastructure level via Caddy) + response = await _send_message_internal(message_request, user, db, []) + await manager.broadcast({ + "type": "newMessage", + "data": response["message"] + }, db) + + log(manager, websocket, user, "sendMessage", message_id=response["message"]["id"]) + return response + + +@websocket_handler("dmSend", authRequired=True) +async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Send a direct message.""" + payload = data + required = ["recipientId", "iv", "ciphertext", "salt", "iv2", "wrappedMk"] + for key in required: + if key not in payload: + raise HTTPException(status_code=400, detail=f"Missing {key}") + + env = DMEnvelope( + sender_id=user.id, + recipient_id=int(payload["recipientId"]), + iv_b64=payload["iv"], + ciphertext_b64=payload["ciphertext"], + salt_b64=payload["salt"], + iv2_b64=payload["iv2"], + wrapped_mk_b64=payload["wrappedMk"], + reply_to_id=payload.get("replyToId") if isinstance(payload.get("replyToId"), int) else None, + ) + db.add(env) + db.commit() + db.refresh(env) + + payload_ws = { + "type": "dmNew", + "data": { + "id": env.id, + "senderId": env.sender_id, + "recipientId": env.recipient_id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "salt": env.salt_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "timestamp": env.timestamp.isoformat(), + "replyToId": env.reply_to_id, + } + } + + # Send push notification for DM + try: + from push_service import push_service + await push_service.send_dm_notification(db, env, user) + except Exception as e: + logger.error(f"Failed to send push notification for DM {env.id}: {e}") + + await manager.send_update_to_user(env.recipient_id, "dmNew", payload_ws["data"], db) + await manager.send_update_to_user(env.sender_id, "dmNew", payload_ws["data"], db) + + log(manager, websocket, user, "dmSend", dm_envelope_id=env.id, recipient_id=env.recipient_id) + log_dm( + "message_sent_ws", + dm_envelope_id=env.id, + sender_id=user.id, + sender_username=user.username, + recipient_id=env.recipient_id, + reply_to=env.reply_to_id, + ) + + return {"status": "ok", "id": env.id} + + +@websocket_handler("editMessage", authRequired=True) +async def editMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Edit a public chat message.""" + + message_id = data["message_id"] + edit_request: EditMessageRequest = EditMessageRequest.model_validate(data) + + response = await _edit_message_internal(message_id, edit_request, user, db) + await manager.broadcast({ + "type": "messageEdited", + "data": response["message"] + }, db) + + log(manager, websocket, user, "editMessage", message_id=message_id) + return response + + +@websocket_handler("dmEdit", authRequired=True) +async def dmEdit(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Edit a direct message.""" + payload = data + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != user.id: + raise HTTPException(status_code=403, detail="You can only edit your own messages") + + # Replace ciphertext and iv + env.iv_b64 = payload["iv"] + env.ciphertext_b64 = payload["ciphertext"] + env.iv2_b64 = payload["iv2"] + env.wrapped_mk_b64 = payload["wrappedMk"] + env.salt_b64 = payload["salt"] + db.commit() + db.refresh(env) + + payload_ws = { + "type": "dmEdited", + "data": { + "id": env.id, + "senderId": env.sender_id, + "recipientId": env.recipient_id, + "iv": env.iv_b64, + "ciphertext": env.ciphertext_b64, + "iv2": env.iv2_b64, + "wrappedMk": env.wrapped_mk_b64, + "salt": env.salt_b64, + "timestamp": env.timestamp.isoformat(), + } + } + await manager.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db) + await manager.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db) + + log(manager, websocket, user, "dmEdit", dm_envelope_id=env.id) + log_dm( + "message_edited", + dm_envelope_id=env.id, + user_id=user.id, + username=user.username, + ) + + return {"status": "ok", "id": env.id} + + +@websocket_handler("dmDelete", authRequired=True) +async def dmDelete(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Delete a direct message.""" + payload = data + env_id = int(payload["id"]) + env: DMEnvelope | None = db.query(DMEnvelope).filter(DMEnvelope.id == env_id).first() + if not env: + raise HTTPException(status_code=404, detail="DM not found") + if env.sender_id != user.id: + raise HTTPException(status_code=403, detail="You can only delete your own messages") + + db.delete(env) + db.commit() + + payload_ws = { + "type": "dmDeleted", + "data": { + "id": env_id, + "senderId": user.id, + "recipientId": payload.get("recipientId") + } + } + await manager.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db) + await manager.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db) + + log(manager, websocket, user, "dmDelete", dm_envelope_id=env_id) + log_dm( + "message_deleted", + dm_envelope_id=env_id, + user_id=user.id, + username=user.username, + recipient_id=env.recipient_id, + ) + + return {"status": "ok", "id": env_id} + + +@websocket_handler("deleteMessage", authRequired=True) +async def deleteMessage(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Delete a public chat message.""" + message_id = data["message_id"] + response = await delete_message(message_id, user, db) + await manager.broadcast({ + "type": "messageDeleted", + "data": {"message_id": message_id} + }, db) + + log(manager, websocket, user, "deleteMessage", message_id=message_id) + return response + + +@websocket_handler("addReaction", authRequired=True) +async def addReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Add or remove a reaction to a public chat message.""" + reaction_request = ReactionRequest( + message_id=data["message_id"], + emoji=data["emoji"] + ) + + response = await add_reaction(reaction_request, user, db) + + # Broadcast reaction update + await manager.broadcast({ + "type": "reactionUpdate", + "data": { + "message_id": data["message_id"], + "emoji": data["emoji"], + "action": response["action"], + "user_id": user.id, + "username": user.username, + "reactions": response["reactions"] + } + }, db) + + log(manager, websocket, user, "addReaction", message_id=data["message_id"], emoji=data["emoji"], action=response["action"]) + return response + + +@websocket_handler("addDmReaction", authRequired=True) +async def addDmReaction(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Add or remove a reaction to a direct message.""" + reaction_request = DMReactionRequest( + dm_envelope_id=data["dm_envelope_id"], + emoji=data["emoji"] + ) + + response = await add_dm_reaction(reaction_request, user, db) + + # Broadcast reaction update + await manager.broadcast({ + "type": "dmReactionUpdate", + "data": { + "dm_envelope_id": data["dm_envelope_id"], + "emoji": data["emoji"], + "action": response["action"], + "user_id": user.id, + "username": user.username, + "reactions": response["reactions"] + } + }, db) + + log(manager, websocket, user, "addDmReaction", dm_envelope_id=data["dm_envelope_id"], emoji=data["emoji"], action=response["action"]) + return response + + +@websocket_handler("call_signaling", authRequired=True) +async def call_signaling(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Forward WebRTC signaling between peers.""" + payload = data or {} + to_user_id = int(payload.get("toUserId") or 0) + if not to_user_id: + raise HTTPException(status_code=400, detail="Missing toUserId") + + # Ensure sender is set by the server + payload["fromUserId"] = user.id + payload["fromUsername"] = user.username + + await manager.send_to_user(to_user_id, { + "type": "call_signaling", + "data": payload + }) + + log(manager, websocket, user, "call_signaling", to_user_id=to_user_id) + return {"status": "ok"} + + +@websocket_handler("call_video_toggle", authRequired=True) +async def call_video_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Forward video toggle state between peers.""" + payload = data or {} + to_user_id = int(payload.get("toUserId") or 0) + if not to_user_id: + raise HTTPException(status_code=400, detail="Missing toUserId") + + await manager.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_video_toggle", + "fromUserId": user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) + + log(manager, websocket, user, "call_video_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False)) + return {"status": "ok"} + + +@websocket_handler("call_screen_share_toggle", authRequired=True) +async def call_screen_share_toggle(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Forward screen share toggle state between peers.""" + payload = data or {} + to_user_id = int(payload.get("toUserId") or 0) + if not to_user_id: + raise HTTPException(status_code=400, detail="Missing toUserId") + + await manager.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_screen_share_toggle", + "fromUserId": user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) + + log(manager, websocket, user, "call_screen_share_toggle", to_user_id=to_user_id, enabled=payload.get("enabled", False)) + return {"status": "ok"} + + +@websocket_handler("subscribeStatus", authRequired=True) +async def subscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Subscribe to status updates for a user.""" + user_id_to_subscribe = int(data["userId"]) + manager.ws_subscriptions[websocket].add(user_id_to_subscribe) + + # Get current status of the user + target_user = db.query(User).filter(User.id == user_id_to_subscribe).first() + if target_user: + # Send current status directly (not through return value) + await websocket.send_json({ + "type": "statusUpdate", + "data": { + "userId": user_id_to_subscribe, + "online": target_user.online, + "lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None + } + }) + log(manager, websocket, user, "subscribeStatus", target_user_id=user_id_to_subscribe) + return {"status": "ok"} + else: + log(manager, websocket, user, "subscribeStatus_error", target_user_id=user_id_to_subscribe, error="User not found") + raise HTTPException(status_code=404, detail="User not found") + + +@websocket_handler("unsubscribeStatus", authRequired=True) +async def unsubscribeStatus(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> dict | None: + """Unsubscribe from status updates for a user.""" + user_id_to_unsubscribe = int(data["userId"]) + manager.ws_subscriptions[websocket].discard(user_id_to_unsubscribe) + + log(manager, websocket, user, "unsubscribeStatus", target_user_id=user_id_to_unsubscribe) + return {"status": "ok"} + + +@websocket_handler("typing", authRequired=True) +async def typing(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator start for public chat.""" + was_typing = manager.typing_state.get(user.id, False) + manager.typing_users[user.id] = time.time() + + # Only send update if state changed (started typing) + if not was_typing: + manager.typing_state[user.id] = True + # Broadcast to all connected users + await manager.broadcast({ + "type": "typing", + "data": { + "userId": user.id, + "username": user.username + } + }, db) + + # No confirmation response - privacy protection + + +@websocket_handler("stopTyping", authRequired=True) +async def stopTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator stop for public chat.""" + was_typing = manager.typing_state.get(user.id, False) + if user.id in manager.typing_users: + del manager.typing_users[user.id] + + # Only send update if state changed (stopped typing) + if was_typing: + manager.typing_state[user.id] = False + # Broadcast to all connected users + await manager.broadcast({ + "type": "stopTyping", + "data": { + "userId": user.id, + "username": user.username + } + }, db) + + # No confirmation response - privacy protection + log(manager, websocket, user, "stopTyping") + + +@websocket_handler("dmTyping", authRequired=True) +async def dmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator start for DM.""" + recipient_id = int(data["recipientId"]) + + if user.id not in manager.dm_typing_users: + manager.dm_typing_users[user.id] = {} + if user.id not in manager.dm_typing_state: + manager.dm_typing_state[user.id] = {} + + was_typing = manager.dm_typing_state[user.id].get(recipient_id, False) + manager.dm_typing_users[user.id][recipient_id] = time.time() + + # Only send update if state changed (started typing) + if not was_typing: + manager.dm_typing_state[user.id][recipient_id] = True + # Send only to recipient + await manager.send_update_to_user(recipient_id, "dmTyping", { + "userId": user.id, + "username": user.username + }, db) + + # No confirmation response - privacy protection + + +@websocket_handler("stopDmTyping", authRequired=True) +async def stopDmTyping(manager: MessaggingSocketManager, websocket: WebSocket, db: Session, user: User, data: dict) -> None: + """Handle typing indicator stop for DM.""" + recipient_id = int(data["recipientId"]) + + was_typing = False + if user.id in manager.dm_typing_state: + was_typing = manager.dm_typing_state[user.id].get(recipient_id, False) + + if user.id in manager.dm_typing_users and recipient_id in manager.dm_typing_users[user.id]: + del manager.dm_typing_users[user.id][recipient_id] + if not manager.dm_typing_users[user.id]: + del manager.dm_typing_users[user.id] + + # Only send update if state changed (stopped typing) + if was_typing: + if user.id in manager.dm_typing_state: + manager.dm_typing_state[user.id][recipient_id] = False + # Send only to recipient + await manager.send_update_to_user(recipient_id, "stopDmTyping", { + "userId": user.id, + "username": user.username + }, db) + + # No confirmation response - privacy protection + diff --git a/backend/websocket/registry.py b/backend/websocket/registry.py new file mode 100644 index 0000000..d9a6271 --- /dev/null +++ b/backend/websocket/registry.py @@ -0,0 +1,33 @@ +from typing import Callable + + +class WebSocketHandlerRegistry: + """Registry for WebSocket message handlers with authentication support.""" + + def __init__(self): + self._handlers: dict[str, tuple[Callable, bool]] = {} + + def register(self, message_type: str, authRequired: bool = True): + """Register a handler for a message type. + + Args: + message_type: The WebSocket message type to handle + authRequired: If True, handler will receive authenticated User (not None) or raise 401 + """ + def decorator(func: Callable): + self._handlers[message_type] = (func, authRequired) + return func + return decorator + + def get_handler(self, message_type: str) -> tuple[Callable, bool] | None: + """Get handler and authRequired flag for a message type. + + Returns: + Tuple of (handler function, authRequired flag) or None if not found + """ + return self._handlers.get(message_type) + + def get_all_types(self) -> list[str]: + """Get all registered message types for debugging/logging.""" + return list(self._handlers.keys()) + diff --git a/backend/websocket/utils.py b/backend/websocket/utils.py new file mode 100644 index 0000000..1688706 --- /dev/null +++ b/backend/websocket/utils.py @@ -0,0 +1,92 @@ +from fastapi import HTTPException +from fastapi.security import HTTPAuthorizationCredentials +from sqlalchemy.orm import Session +from types import SimpleNamespace +from dependencies import get_current_user +from models import User + + +def extract_token_from_data(data: dict) -> str | None: + """Extract authentication token from WebSocket message data. + + Args: + data: WebSocket message data dictionary + + Returns: + Token string or None if not present + """ + credentials = data.get("credentials") + if credentials and isinstance(credentials, dict): + return credentials.get("credentials") + return None + + +def get_current_user_from_token(token: str, db: Session) -> User | None: + """Get user from authentication token. + + Args: + token: JWT token string + db: Database session + + Returns: + User object or None if token is invalid + """ + try: + # Ensure session is in a usable state before querying + try: + db.rollback() + except Exception: + pass + + dummy_request = SimpleNamespace() + dummy_request.state = SimpleNamespace() + + try: + from fastapi.security import HTTPBearer + security = HTTPBearer() + # We need to create credentials manually + credentials = HTTPAuthorizationCredentials( + scheme="Bearer", + credentials=token + ) + return get_current_user(dummy_request, credentials, db) + except HTTPException: + return None + except Exception: + try: + db.rollback() + except Exception: + pass + return None + + +def authenticate_user(data: dict, db: Session, authRequired: bool) -> User | None: + """Authenticate user from WebSocket message data. + + Args: + data: WebSocket message data dictionary + db: Database session + authRequired: If True, raises 401 on missing/invalid token + + Returns: + User object (guaranteed not None if authRequired=True) or None + + Raises: + HTTPException: 401 if authRequired=True and token is missing/invalid + """ + token = extract_token_from_data(data) + + if authRequired: + if not token: + raise HTTPException(status_code=401, detail="Missing credentials") + + user = get_current_user_from_token(token, db) + if not user: + raise HTTPException(status_code=401, detail="Invalid credentials") + + return user + else: + if token: + return get_current_user_from_token(token, db) + return None + diff --git a/deployment/.dockerignore b/deployment/.dockerignore index af433d6..d6dd40d 100644 --- a/deployment/.dockerignore +++ b/deployment/.dockerignore @@ -30,4 +30,5 @@ coverage test_results/ out -data \ No newline at end of file +data +logs \ No newline at end of file diff --git a/deployment/Dockerfile.backend b/deployment/Dockerfile.backend index daf7c5b..e2c4fac 100644 --- a/deployment/Dockerfile.backend +++ b/deployment/Dockerfile.backend @@ -14,12 +14,16 @@ FROM python:3.12-slim AS runtime WORKDIR /app RUN useradd -u 1000 app && \ chown -R app /app -USER app # 2.2. Copy content and create dirs COPY --chown=app backend . COPY --from=builder --chown=app /app/.venv .venv -RUN mkdir -p /app/data +RUN mkdir -p /app/data /app/logs && \ + chown -R app /app/data /app/logs && \ + printf '#!/bin/sh\nexec /app/.venv/bin/python /app/admin_cli.py "$@"\n' > /usr/local/bin/admin-cli && \ + chmod +x /usr/local/bin/admin-cli + +USER app # 3. Final command ENTRYPOINT exec ./.venv/bin/fastapi run --port ${PORT:-8300} main.py \ No newline at end of file diff --git a/deployment/caddy/Caddyfile b/deployment/caddy/Caddyfile new file mode 100644 index 0000000..d9809ab --- /dev/null +++ b/deployment/caddy/Caddyfile @@ -0,0 +1,88 @@ +fromchat.ru { + reverse_proxy 172.18.0.1:8301 host.docker.internal:8301 172.17.0.1:8301 { + lb_policy first + header_up X-Real-IP {remote_host} + } + + # Security headers + header { + X-XSS-Protection "1; mode=block" # Prevent XSS attacks + X-Content-Type-Options "nosniff" # Prevent MIME type sniffing + X-Frame-Options "DENY" # Prevent clickjacking + Referrer-Policy "strict-origin-when-cross-origin" + Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + Permissions-Policy "geolocation=(), microphone=(self), camera=(self)" + } + + rate_limit { + zone global { + key {remote_ip} + window 1m + burst 20 + events 500 + } + } + + handle_errors { + @errors { + expression {err.status_code} >= 400 + } + + handle @errors { + rewrite * /{err.status_code} + reverse_proxy https://http.cat { + header_up Host {upstream_hostport} + replace_status {err.status_code} + } + } + } +} + +beta.fromchat.ru { + reverse_proxy 95.165.0.162:8301 { + header_up X-Real-IP {remote_host} + } + + # Security headers + header { + X-XSS-Protection "1; mode=block" # Prevent XSS attacks + X-Content-Type-Options "nosniff" # Prevent MIME type sniffing + X-Frame-Options "DENY" # Prevent clickjacking + Referrer-Policy "strict-origin-when-cross-origin" + Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none';" + Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" + Permissions-Policy "geolocation=(), microphone=(self), camera=(self)" + } + + rate_limit { + zone global { + key {remote_ip} + window 1m + burst 20 + events 1000 + } + } + + handle_errors { + @errors { + expression {err.status_code} >= 400 + } + + handle @errors { + rewrite * /{err.status_code} + reverse_proxy https://http.cat { + header_up Host {upstream_hostport} + replace_status {err.status_code} + } + } + } +} + +api.getgadgets.toolbox-io.ru { + reverse_proxy 95.165.0.162:8400 +} + +getgadgets.toolbox-io.ru { + reverse_proxy 95.165.0.162:8401 +} \ No newline at end of file diff --git a/deployment/caddy/Dockerfile b/deployment/caddy/Dockerfile new file mode 100644 index 0000000..a21acd3 --- /dev/null +++ b/deployment/caddy/Dockerfile @@ -0,0 +1,13 @@ +# +# Custom Caddy built with: +# - Rate limit plugin +# + +FROM caddy:2-builder AS builder +RUN xcaddy build \ + --with github.com/mholt/caddy-ratelimit + +FROM caddy:2 + +COPY --from=builder /usr/bin/caddy /usr/bin/caddy +COPY Caddyfile /etc/caddy/Caddyfile \ No newline at end of file diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index a0cd804..948e241 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -1,6 +1,6 @@ services: backend: - build: + build: dockerfile: deployment/Dockerfile.backend context: .. environment: @@ -8,8 +8,11 @@ services: JWT_SECRET: ${JWT_SECRET} VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} + FIREBASE_CERT: ${FIREBASE_CERT} volumes: - - "data:/app/data" + - data:/app/data + - logs:/app/logs + develop: watch: - action: sync+restart @@ -19,7 +22,7 @@ services: path: ../backend/requirements.txt frontend: - build: + build: dockerfile: deployment/frontend/Dockerfile context: .. environment: @@ -39,6 +42,26 @@ services: - action: rebuild path: package.json + caddy: + build: + context: ./caddy + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "80:80" + - "443:443" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - certs:/root/site/certs + environment: + XDG_DATA_HOME: /root/site/certs + XDG_CONFIG_HOME: /root/site/certs + volumes: data: - name: fromchat-data \ No newline at end of file + name: fromchat-data + logs: + name: fromchat-logs + certs: + name: fromchat-certs \ No newline at end of file diff --git a/deployment/frontend/Dockerfile b/deployment/frontend/Dockerfile index 41c7b43..8bad32e 100644 --- a/deployment/frontend/Dockerfile +++ b/deployment/frontend/Dockerfile @@ -3,11 +3,13 @@ FROM node:24 AS frontend # 1.1. Install npm dependencies WORKDIR /app +# Copy package.json and workspace package directory first (needed for workspace resolution) COPY package.json . +COPY frontend/packages/ frontend/packages/ RUN --mount=type=cache,target=/root/.npm \ npm install --ignore-scripts -# 1.2. Build +# 1.2. Copy remaining frontend code and build COPY frontend frontend RUN npm run frontend:build diff --git a/frontend/index.html b/frontend/index.html index 9038e1e..e0e69ad 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Loading... - +
diff --git a/frontend/packages/fromchat-protocol/.gitignore b/frontend/packages/fromchat-protocol/.gitignore new file mode 100644 index 0000000..6e1dbeb --- /dev/null +++ b/frontend/packages/fromchat-protocol/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +*.log +.DS_Store +package-lock.json + + diff --git a/frontend/packages/fromchat-protocol/.npmignore b/frontend/packages/fromchat-protocol/.npmignore new file mode 100644 index 0000000..606c52a --- /dev/null +++ b/frontend/packages/fromchat-protocol/.npmignore @@ -0,0 +1,8 @@ +src/ +tsconfig.json +node_modules/ +package-lock.json +*.log +.DS_Store + + diff --git a/frontend/packages/fromchat-protocol/PUBLISHING.md b/frontend/packages/fromchat-protocol/PUBLISHING.md new file mode 100644 index 0000000..03f24d1 --- /dev/null +++ b/frontend/packages/fromchat-protocol/PUBLISHING.md @@ -0,0 +1,171 @@ +# Publishing FromChat Protocol + +This guide explains how to publish the `@fromchat/protocol` package to npm or GitHub Packages. + +## Prerequisites + +1. **npm account**: Create one at [npmjs.com](https://www.npmjs.com/signup) +2. **GitHub account**: For GitHub Packages +3. **Node.js**: Version 18 or higher + +## Publishing to npm + +### Important: Scoped Package Setup + +The package uses the `@fromchat` scope. You have two options: + +**Option A: Create an npm organization (Recommended)** +1. Go to [npmjs.com/org/create](https://www.npmjs.com/org/create) +2. Create an organization named `fromchat` +3. Add yourself as a member +4. Then proceed with publishing below + +**Option B: Use unscoped package name** +If you prefer not to create an organization, change the package name in `package.json`: +```json +{ + "name": "fromchat-protocol" // Remove the @fromchat/ scope +} +``` +Then update all imports in your codebase from `@fromchat/protocol` to `fromchat-protocol`. + +### 1. Build the package + +```bash +cd frontend/packages/fromchat-protocol +npm run build +``` + +This compiles TypeScript to JavaScript in the `dist/` directory. + +### 2. Login to npm + +```bash +npm login +``` + +Enter your npm username, password, and email. + +### 3. Publish + +**If using scoped package (`@fromchat/protocol`):** +```bash +npm publish --access public +``` + +**If using unscoped package (`fromchat-protocol`):** +```bash +npm publish +``` + +The `--access public` flag is required for scoped packages (packages starting with `@`). + +### 4. Verify + +Check your package at: `https://www.npmjs.com/package/@fromchat/protocol` + +### 5. Update version for future releases + +```bash +# Patch version (1.0.0 -> 1.0.1) +npm version patch + +# Minor version (1.0.0 -> 1.1.0) +npm version minor + +# Major version (1.0.0 -> 2.0.0) +npm version major + +# Then publish +npm publish --access public +``` + +## Publishing to GitHub Packages + +### 1. Create a GitHub Personal Access Token + +1. Go to GitHub Settings → Developer settings → Personal access tokens → Tokens (classic) +2. Generate a new token with `write:packages` and `read:packages` permissions +3. Save the token securely + +### 2. Configure npm to use GitHub Packages + +Create or edit `~/.npmrc`: + +``` +@fromchat:registry=https://npm.pkg.github.com +//npm.pkg.github.com/:_authToken=YOUR_GITHUB_TOKEN +``` + +Or add to `package.json`: + +```json +{ + "publishConfig": { + "registry": "https://npm.pkg.github.com" + } +} +``` + +### 3. Update package.json + +Update the repository URL to match your GitHub repository: + +```json +{ + "repository": { + "type": "git", + "url": "https://github.com/YOUR_USERNAME/YOUR_REPO.git", + "directory": "frontend/packages/fromchat-protocol" + } +} +``` + +### 4. Build and publish + +```bash +cd frontend/packages/fromchat-protocol +npm run build +npm publish +``` + +### 5. Install from GitHub Packages + +Users can install your package with: + +```bash +npm install @fromchat/protocol@npm:@fromchat/protocol +``` + +Or add to `.npmrc`: + +``` +@fromchat:registry=https://npm.pkg.github.com +``` + +## Using the Published Package + +### From npm + +```bash +npm install @fromchat/protocol +``` + +```typescript +import { FromChatProtocol } from "@fromchat/protocol"; +``` + +### From GitHub Packages + +```bash +npm install @fromchat/protocol@npm:@fromchat/protocol +``` + +## Notes + +- The package is built to `dist/` directory +- Source files in `src/` are excluded from the published package +- Only `dist/` and `README.md` are included in the published package +- The package uses ES modules (ESM) format +- TypeScript definitions are included in `dist/` + diff --git a/frontend/packages/fromchat-protocol/README.md b/frontend/packages/fromchat-protocol/README.md new file mode 100644 index 0000000..baa9d0f --- /dev/null +++ b/frontend/packages/fromchat-protocol/README.md @@ -0,0 +1,99 @@ +# FromChat Protocol + +Simple ECDH-based encryption protocol for direct messages. + +## Overview + +The FromChat Protocol provides end-to-end encryption for direct messages using: +- **X25519** (ECDH) for key exchange +- **HKDF** for key derivation +- **AES-GCM** for symmetric encryption + +This module is completely independent and can be used in any JavaScript/TypeScript project. + +## Protocol Flow + +### Encryption + +1. Generate a random message key (mk) - 32 bytes +2. Generate a random salt (wkSalt) - 16 bytes +3. Derive shared secret from ECDH: `ecdhSharedSecret(myPrivateKey, theirPublicKey)` +4. Derive wrapping key: `deriveWrappingKey(sharedSecret, wkSalt, info)` using HKDF +5. Encrypt message with mk using AES-GCM → (iv, ciphertext) +6. Encrypt (wrap) mk with wrapping key using AES-GCM → (iv2, wrappedMk) +7. Send: `{ iv, ciphertext, salt, iv2, wrappedMk }` + +### Decryption + +1. Derive shared secret from ECDH +2. Derive wrapping key from shared secret using salt from message +3. Decrypt wrappedMk to get mk +4. Decrypt ciphertext with mk + +## Usage + +```typescript +import { FromChatProtocol } from "@fromchat/protocol"; + +// Initialize with your private key +const protocol = new FromChatProtocol(privateKey); + +// Encrypt a message +const encrypted = await protocol.encryptMessage(recipientPublicKey, "Hello!"); + +// Decrypt a message +const decrypted = await protocol.decryptMessage(senderPublicKey, encrypted); +``` + +## API + +### `FromChatProtocol` + +#### Constructor +- `constructor(privateKey: Uint8Array)` - Initialize protocol with your X25519 private key + +#### Methods +- `encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise` - Encrypt a message +- `decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise` - Decrypt a message + +### Types + +```typescript +interface EncryptedMessage { + iv: string; // Base64 encoded IV for message encryption + ciphertext: string; // Base64 encoded encrypted message + salt: string; // Base64 encoded salt for wrapping key derivation + iv2: string; // Base64 encoded IV for message key wrapping + wrappedMk: string; // Base64 encoded wrapped message key +} +``` + +## Backup & Key Management + +The protocol also includes utilities for backing up and restoring private keys: + +```typescript +import { + encryptBackupWithPassword, + decryptBackupWithPassword, + encodeBlob, + decodeBlob +} from "@fromchat/protocol"; + +// Create a backup of a private key +const bundle = { version: 1, privateKey: myPrivateKey }; +const encrypted = await encryptBackupWithPassword("my-password", bundle); +const backupString = encodeBlob(encrypted); // Store this string + +// Restore from backup +const encryptedBlob = decodeBlob(backupString); +const restored = await decryptBackupWithPassword("my-password", encryptedBlob); +``` + +## Security Notes + +- Each message uses a fresh random message key +- The protocol does not provide forward secrecy +- Keys are derived using HKDF with SHA-256 +- All encryption uses AES-GCM with 12-byte IVs +- Backup encryption uses PBKDF2 with 210,000 iterations diff --git a/frontend/packages/fromchat-protocol/package.json b/frontend/packages/fromchat-protocol/package.json new file mode 100644 index 0000000..c457046 --- /dev/null +++ b/frontend/packages/fromchat-protocol/package.json @@ -0,0 +1,54 @@ +{ + "name": "@fromchat/protocol", + "version": "1.0.0", + "description": "FromChat Protocol - Simple ECDH-based encryption for direct messages. Independent and reusable encryption module.", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + } + }, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "encryption", + "ecdh", + "e2ee", + "end-to-end-encryption", + "x25519", + "aes-gcm", + "hkdf" + ], + "author": "denis0001-dev", + "license": "GPL-3.0", + "repository": { + "type": "git", + "url": "https://github.com/Toolbox-io/FromChat.git", + "directory": "frontend/packages/fromchat-protocol" + }, + "bugs": { + "url": "https://github.com/Toolbox-io/FromChat/issues" + }, + "homepage": "https://github.com/Toolbox-io/FromChat#readme", + "dependencies": { + "tweetnacl": "^1.0.3" + }, + "devDependencies": { + "@types/node": "^25.0.2", + "typescript": "^5.0.0" + }, + "files": [ + "dist", + "README.md" + ], + "engines": { + "node": ">=24.0.0" + } +} \ No newline at end of file diff --git a/frontend/src/utils/crypto/backup.ts b/frontend/packages/fromchat-protocol/src/backup/backup.ts similarity index 94% rename from frontend/src/utils/crypto/backup.ts rename to frontend/packages/fromchat-protocol/src/backup/backup.ts index da5d320..327b4c4 100644 --- a/frontend/src/utils/crypto/backup.ts +++ b/frontend/packages/fromchat-protocol/src/backup/backup.ts @@ -1,5 +1,4 @@ -import { aesGcmDecrypt, aesGcmEncrypt } from "./symmetric"; -import { importPassword, deriveKEK, randomBytes } from "./kdf"; +import { aesGcmDecrypt, aesGcmEncrypt, importPassword, deriveKEK, randomBytes } from "../crypto/index"; export interface PrivateKeyBundle { version: 1; @@ -65,4 +64,3 @@ export function decodeBlob(json: string): EncryptedBackupBlob { return { salt: ub64(obj.salt), iv: ub64(obj.iv), ciphertext: ub64(obj.ciphertext) }; } - diff --git a/frontend/src/utils/crypto/asymmetric.ts b/frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts similarity index 82% rename from frontend/src/utils/crypto/asymmetric.ts rename to frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts index 72bf39f..35bde59 100644 --- a/frontend/src/utils/crypto/asymmetric.ts +++ b/frontend/packages/fromchat-protocol/src/crypto/asymmetric.ts @@ -6,18 +6,16 @@ export interface X25519KeyPair { privateKey: Uint8Array; } -export type KeyPair = X25519KeyPair; - export function generateX25519KeyPair(): X25519KeyPair { const kp = nacl.box.keyPair(); return { publicKey: kp.publicKey, privateKey: kp.secretKey }; } export function ecdhSharedSecret(myPrivateKey: Uint8Array, theirPublicKey: Uint8Array): Uint8Array { - // nacl.box.before returns shared key (Curve25519, XSalsa20-Poly1305 context). We use it as IKM into HKDF. return nacl.box.before(theirPublicKey, myPrivateKey); } export async function deriveWrappingKey(sharedSecret: Uint8Array, salt: Uint8Array, info: Uint8Array): Promise { return hkdfExtractAndExpand(sharedSecret.buffer as ArrayBuffer, salt, info, 32); -} \ No newline at end of file +} + diff --git a/frontend/packages/fromchat-protocol/src/crypto/index.ts b/frontend/packages/fromchat-protocol/src/crypto/index.ts new file mode 100644 index 0000000..49a1b0b --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/crypto/index.ts @@ -0,0 +1,7 @@ +// Re-export all crypto functions for convenience +export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./asymmetric"; +export type { X25519KeyPair } from "./asymmetric"; +export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./symmetric"; +export type { AesGcmCiphertext } from "./symmetric"; +export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./kdf"; + diff --git a/frontend/src/utils/crypto/kdf.ts b/frontend/packages/fromchat-protocol/src/crypto/kdf.ts similarity index 99% rename from frontend/src/utils/crypto/kdf.ts rename to frontend/packages/fromchat-protocol/src/crypto/kdf.ts index b58a133..158e272 100644 --- a/frontend/src/utils/crypto/kdf.ts +++ b/frontend/packages/fromchat-protocol/src/crypto/kdf.ts @@ -1,3 +1,19 @@ +export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise { + const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial; + const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; + const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info; + + const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]); + const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8); + return new Uint8Array(bits); +} + +export function randomBytes(length: number): Uint8Array { + const out = new Uint8Array(length); + crypto.getRandomValues(out); + return out; +} + export async function importPassword(password: string): Promise { const enc = new TextEncoder(); return crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveKey", "deriveBits"]); @@ -13,19 +29,3 @@ export async function deriveKEK(passwordKey: CryptoKey, salt: Uint8Array | Array ["encrypt", "decrypt"] ); } - -export async function hkdfExtractAndExpand(inputKeyMaterial: Uint8Array | ArrayBuffer, salt: Uint8Array | ArrayBuffer, info: Uint8Array | ArrayBuffer, length = 32): Promise { - const inputBuffer = inputKeyMaterial instanceof Uint8Array ? inputKeyMaterial.buffer as ArrayBuffer : inputKeyMaterial; - const saltBuffer = salt instanceof Uint8Array ? salt.buffer as ArrayBuffer : salt; - const infoBuffer = info instanceof Uint8Array ? info.buffer as ArrayBuffer : info; - - const ikmKey = await crypto.subtle.importKey("raw", inputBuffer, { name: "HKDF" }, false, ["deriveBits"]); - const bits = await crypto.subtle.deriveBits({ name: "HKDF", hash: "SHA-256", salt: saltBuffer, info: infoBuffer }, ikmKey, length * 8); - return new Uint8Array(bits); -} - -export function randomBytes(length: number): Uint8Array { - const out = new Uint8Array(length); - crypto.getRandomValues(out); - return out; -} \ No newline at end of file diff --git a/frontend/src/utils/crypto/symmetric.ts b/frontend/packages/fromchat-protocol/src/crypto/symmetric.ts similarity index 88% rename from frontend/src/utils/crypto/symmetric.ts rename to frontend/packages/fromchat-protocol/src/crypto/symmetric.ts index 804680b..4822392 100644 --- a/frontend/src/utils/crypto/symmetric.ts +++ b/frontend/packages/fromchat-protocol/src/crypto/symmetric.ts @@ -11,12 +11,10 @@ export async function aesGcmEncrypt(key: CryptoKey, plaintext: Uint8Array | Arra } export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer, ciphertext: Uint8Array | ArrayBuffer): Promise { - // Normalize IV to ArrayBuffer (12 bytes for AES-GCM) const ivBuf: ArrayBuffer = iv instanceof Uint8Array ? (iv.buffer as ArrayBuffer).slice(iv.byteOffset, iv.byteOffset + iv.byteLength) : (iv as ArrayBuffer); - // Normalize ciphertext to a contiguous ArrayBuffer slice const ctBuf: ArrayBuffer = ciphertext instanceof Uint8Array ? (ciphertext.buffer as ArrayBuffer).slice(ciphertext.byteOffset, ciphertext.byteOffset + ciphertext.byteLength) : (ciphertext as ArrayBuffer); @@ -26,9 +24,8 @@ export async function aesGcmDecrypt(key: CryptoKey, iv: Uint8Array | ArrayBuffer } export async function importAesGcmKey(rawKey: Uint8Array | ArrayBuffer): Promise { - // Normalize to a contiguous ArrayBuffer slice to avoid offset/length issues const keyBuffer = rawKey instanceof Uint8Array ? (rawKey.buffer as ArrayBuffer).slice(rawKey.byteOffset, rawKey.byteOffset + rawKey.byteLength) : (rawKey as ArrayBuffer); return crypto.subtle.importKey("raw", keyBuffer, { name: "AES-GCM" }, false, ["encrypt", "decrypt"]); -} \ No newline at end of file +} diff --git a/frontend/packages/fromchat-protocol/src/index.ts b/frontend/packages/fromchat-protocol/src/index.ts new file mode 100644 index 0000000..ddc271c --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/index.ts @@ -0,0 +1,20 @@ +export { FromChatProtocol } from "./protocol/FromChatProtocol"; +export type { EncryptedMessage } from "./protocol/types"; + +// Export crypto functions +export { generateX25519KeyPair, ecdhSharedSecret, deriveWrappingKey } from "./crypto/asymmetric"; +export type { X25519KeyPair } from "./crypto/asymmetric"; +export { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "./crypto/symmetric"; +export type { AesGcmCiphertext } from "./crypto/symmetric"; +export { hkdfExtractAndExpand, randomBytes, importPassword, deriveKEK } from "./crypto/kdf"; + +// Export backup functions +export { + encryptBackupWithPassword, + decryptBackupWithPassword, + encodeBlob, + decodeBlob, + serializeBundle, + deserializeBundle +} from "./backup/backup"; +export type { PrivateKeyBundle, EncryptedBackupBlob } from "./backup/backup"; diff --git a/frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts b/frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts new file mode 100644 index 0000000..88bef98 --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts @@ -0,0 +1,102 @@ +import { ecdhSharedSecret, deriveWrappingKey } from "../crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "../crypto/symmetric"; +import { randomBytes } from "../crypto/kdf"; +import type { EncryptedMessage } from "./types"; + +/** + * FromChat Protocol - Simple ECDH-based encryption + * + * Protocol: + * 1. Generate random message key (mk) - 32 bytes + * 2. Generate random salt (wkSalt) - 16 bytes + * 3. Derive shared secret from ECDH (X25519) + * 4. Derive wrapping key from shared secret using HKDF with salt + * 5. Encrypt message with mk using AES-GCM + * 6. Encrypt (wrap) mk with wrapping key using AES-GCM + * 7. Send: { iv, ciphertext, salt, iv2, wrappedMk } + */ +export class FromChatProtocol { + private privateKey: Uint8Array; + + constructor(privateKey: Uint8Array) { + this.privateKey = privateKey; + } + + /** + * Encrypt a message for a recipient + * @param recipientPublicKey - Recipient's X25519 public key + * @param plaintext - Message to encrypt + * @returns Encrypted message with all necessary fields + */ + async encryptMessage(recipientPublicKey: Uint8Array, plaintext: string): Promise { + // Generate random message key + const mk = randomBytes(32); + + // Generate random salt for wrapping key derivation + const wkSalt = randomBytes(16); + + // Derive shared secret from ECDH + const shared = ecdhSharedSecret(this.privateKey, recipientPublicKey); + + // Derive wrapping key from shared secret using HKDF + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Encrypt the message with message key + const plaintextBytes = new TextEncoder().encode(plaintext); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), plaintextBytes); + + // Encrypt (wrap) the message key with wrapping key + const wrap = await aesGcmEncrypt(wk, mk); + + // Convert to base64 for transmission + return { + iv: btoa(String.fromCharCode(...encMsg.iv)), + ciphertext: btoa(String.fromCharCode(...encMsg.ciphertext)), + salt: btoa(String.fromCharCode(...wkSalt)), + iv2: btoa(String.fromCharCode(...wrap.iv)), + wrappedMk: btoa(String.fromCharCode(...wrap.ciphertext)) + }; + } + + /** + * Decrypt a message from a sender + * @param senderPublicKey - Sender's X25519 public key + * @param message - Encrypted message + * @returns Decrypted plaintext + */ + async decryptMessage(senderPublicKey: Uint8Array, message: EncryptedMessage): Promise { + // Decode base64 fields + const salt = new Uint8Array( + atob(message.salt).split("").map(c => c.charCodeAt(0)) + ); + const iv2 = new Uint8Array( + atob(message.iv2).split("").map(c => c.charCodeAt(0)) + ); + const wrappedMk = new Uint8Array( + atob(message.wrappedMk).split("").map(c => c.charCodeAt(0)) + ); + const iv = new Uint8Array( + atob(message.iv).split("").map(c => c.charCodeAt(0)) + ); + const ciphertext = new Uint8Array( + atob(message.ciphertext).split("").map(c => c.charCodeAt(0)) + ); + + // Derive shared secret from ECDH + const shared = ecdhSharedSecret(this.privateKey, senderPublicKey); + + // Derive wrapping key from shared secret using salt from message + const wkRaw = await deriveWrappingKey(shared, salt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Decrypt (unwrap) the message key + const mk = await aesGcmDecrypt(wk, iv2, wrappedMk); + + // Decrypt the message with message key + const decrypted = await aesGcmDecrypt(await importAesGcmKey(mk), iv, ciphertext); + + return new TextDecoder().decode(decrypted); + } +} + diff --git a/frontend/packages/fromchat-protocol/src/protocol/types.ts b/frontend/packages/fromchat-protocol/src/protocol/types.ts new file mode 100644 index 0000000..049250e --- /dev/null +++ b/frontend/packages/fromchat-protocol/src/protocol/types.ts @@ -0,0 +1,10 @@ +/** + * Encrypted message format + */ +export interface EncryptedMessage { + iv: string; // Base64 encoded IV for message encryption + ciphertext: string; // Base64 encoded encrypted message + salt: string; // Base64 encoded salt for wrapping key derivation + iv2: string; // Base64 encoded IV for message key wrapping + wrappedMk: string; // Base64 encoded wrapped message key +} diff --git a/frontend/packages/fromchat-protocol/tsconfig.json b/frontend/packages/fromchat-protocol/tsconfig.json new file mode 100644 index 0000000..6ac621c --- /dev/null +++ b/frontend/packages/fromchat-protocol/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "lib": ["ES2020", "DOM"], + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "moduleResolution": "node", + "resolveJsonModule": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/frontend/plugins/optimizeSvg.ts b/frontend/plugins/optimizeSvg.ts new file mode 100644 index 0000000..9b1d120 --- /dev/null +++ b/frontend/plugins/optimizeSvg.ts @@ -0,0 +1,67 @@ +import type { Plugin } from 'vite'; +import { optimize } from 'svgo'; + +export interface OptimizeSvgOptions { + /** + * Whether to enable SVG optimization + * @default true + */ + enabled?: boolean; +} + +const svgoConfig: Parameters[1] = { + multipass: true, + plugins: [ + { + name: 'preset-default', + params: { + overrides: { + // Keep IDs if they might be referenced (minify instead of remove) + cleanupIds: { + remove: false, + minify: true + } + } + } + } + ] +}; + +/** + * Optimizes SVG files during build by: + * - Minifying SVG code + * - Removing metadata and comments + * - Removing unnecessary attributes + * - Optimizing paths and shapes + */ +export function optimizeSvg(options?: OptimizeSvgOptions): Plugin { + const enabled = options?.enabled !== false; + + return { + name: 'optimize-svg', + apply: 'build', + enforce: 'post', + async generateBundle(options, bundle) { + if (!enabled) return; + + // Optimize SVGs in the bundle + for (const [fileName, chunk] of Object.entries(bundle)) { + if (fileName.endsWith('.svg') && chunk.type === 'asset') { + try { + const svgContent = typeof chunk.source === 'string' + ? chunk.source + : Buffer.from(chunk.source).toString('utf-8'); + + const result = optimize(svgContent, svgoConfig); + + if (result.data && result.data !== svgContent) { + chunk.source = result.data; + } + } catch (error) { + console.warn(`Failed to optimize SVG ${fileName}:`, error); + } + } + } + } + }; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7cb4c96..6dd9d08 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,26 @@ -import { BrowserRouter, Routes, Route, useNavigate, matchRoutes, type RouteObject } from "react-router-dom"; +import { BrowserRouter, Routes, Route, useNavigate, useLocation, matchRoutes, Navigate, type RouteObject } from "react-router-dom"; +import { AnimatePresence, motion } from "motion/react"; import { ElectronTitleBar } from "./Electron"; -import { useAppState } from "./pages/chat/state"; -import { lazy, useEffect, useState } from "react"; +import { useUserStore } from "./state/user"; +import { lazy, useEffect, useRef, useState } from "react"; import { parseProfileLink } from "./core/profileLinks"; import NotFoundPage from "./pages/not-found/NotFoundPage"; import ProtectedRoute from "./pages/ProtectedRoute"; import DownloadAppPage from "./pages/download-app/DownloadAppPage"; import { SuspensionDialog } from "./pages/chat/ui/SuspensionDialog"; +import { AlertDialogProvider } from "./core/components/AlertDialog"; +import { delay } from "./utils/utils"; // Lazy load route components const HomePage = lazy(() => import("./pages/home/HomePage")); -const LoginPage = lazy(() => import("./pages/auth/LoginPage")); -const RegisterPage = lazy(() => import("./pages/auth/RegisterPage")); +const AuthPage = lazy(() => import("./pages/auth/AuthPage")); const ChatPage = lazy(() => import("./pages/chat/ui/ChatPage")); const routeConfig: RouteObject[] = [ { path: "/", element: }, - { path: "/login", element: }, - { path: "/register", element: }, + { path: "/auth", element: }, + { path: "/login", element: }, + { path: "/register", element: }, { path: "/download-app", element: }, { path: "/chat", @@ -66,25 +69,70 @@ function SmartCatchAll() { } } -export default function App() { - const { restoreUserFromStorage, user } = useAppState(); - const [authReady, setAuthReady] = useState(false); +function AnimatedRoutes() { + const location = useLocation(); + const prevPathnameRef = useRef(location.pathname); - useEffect(() => { - restoreUserFromStorage().finally(() => { - setAuthReady(true); - }); - }, [restoreUserFromStorage]); - - return authReady && ( - - -
- + return ( + + { + if (prevPathnameRef.current !== location.pathname) { + prevPathnameRef.current = location.pathname; + document.body.style.overflow = "hidden"; + } + }} + onAnimationComplete={async () => { + await delay(500); + document.body.style.overflow = ""; + }} + initial={{ opacity: 0, scale: 0.8 }} + animate={{ opacity: 1, scale: 1 }} + exit={{ opacity: 1, scale: 1.1 }} + transition={{ + type: "spring", + stiffness: 300, + damping: 30, + mass: 0.8 + }} + style={{ + transformOrigin: "center center", + width: "100%", + height: "100%", + position: "absolute", + top: 0, + left: 0, + right: 0, + bottom: 0 + }} + > + {routeConfig.map((route, index) => ( ))} + + + ); +} + +export default function App() { + const { restoreFromStorage, user } = useUserStore(); + const [authReady, setAuthReady] = useState(false); + + useEffect(() => { + restoreFromStorage().finally(() => { + setAuthReady(true); + }); + }, [restoreFromStorage]); + + return authReady && ( + + + +
+
{user.isSuspended && ( { - const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token) }); + const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) }); if (!res.ok) throw new Error("Failed to fetch devices"); const data = await res.json(); return data.devices as DeviceInfo[]; } export async function revokeDevice(token: string, sessionId: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token) }); + const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) }); if (!res.ok) throw new Error("Failed to revoke device"); } export async function logoutAllOtherDevices(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token) }); + const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) }); if (!res.ok) throw new Error("Failed to logout all devices"); } - diff --git a/frontend/src/core/api/authApi.ts b/frontend/src/core/api/account/index.ts similarity index 52% rename from frontend/src/core/api/authApi.ts rename to frontend/src/core/api/account/index.ts index a95c08f..9b9fdbc 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/account/index.ts @@ -1,12 +1,13 @@ -import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types"; -import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; -import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; -import { b64, ub64 } from "@/utils/utils"; import { API_BASE_URL } from "@/core/config"; -import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; +import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types"; +import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol"; +import { b64, ub64 } from "@/utils/utils"; +import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; +import type { Headers } from "@/core/types"; /** * Generates authentication headers for API requests + * @param {string | null} token - Authentication token * @param {boolean} json - Whether to include JSON content type header * @returns {Headers} Headers object with authentication and content type */ @@ -23,54 +24,15 @@ export function getAuthHeaders(token: string | null, json: boolean = true): Head return headers; } -let currentPublicKey: Uint8Array | null = null; -let currentPrivateKey: Uint8Array | null = null; - -async function fetchPublicKey(token: string): Promise { - const headers = getAuthHeaders(token, true); - const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers }); - if (!res.ok) return null; - const data = await res.json(); - if (!data?.publicKey) return null; - return ub64(data.publicKey); +export interface CheckAuthResponse { + authenticated: boolean; + username: string; + admin: boolean; } -async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { - const payload: UploadPublicKeyRequest = { - publicKey: b64(publicKey) - } - - const headers = getAuthHeaders(token, true); - await fetch(`${API_BASE_URL}/crypto/public-key`, { - method: "POST", - headers, - body: JSON.stringify(payload) - }); -} - -async function fetchBackupBlob(token: string): Promise { - const headers = getAuthHeaders(token, true); - const res = await fetch(`${API_BASE_URL}/crypto/backup`, { - method: "GET", - headers - }); - if (res.ok) { - const response: BackupBlob = await res.json(); - return response.blob; - } else { - return null; - } -} - -async function uploadBackupBlob(blobJson: string, token: string): Promise { - const payload: BackupBlob = { blob: blobJson } - - const headers = getAuthHeaders(token, true); - await fetch(`${API_BASE_URL}/crypto/backup`, { - method: "POST", - headers, - body: JSON.stringify(payload) - }); +export interface LogoutResponse { + status: string; + message: string; } export interface UserKeyPairMemory { @@ -78,6 +40,9 @@ export interface UserKeyPairMemory { privateKey: Uint8Array; } +let currentPublicKey: Uint8Array | null = null; +let currentPrivateKey: Uint8Array | null = null; + export function getCurrentKeys(): UserKeyPairMemory | null { if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; return null; @@ -94,6 +59,72 @@ function saveKeys( localStorage.setItem("privateKey", encodedPrivateKey); } +/** + * Checks if the current user is authenticated + */ +export async function checkAuth(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/check_auth`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to check auth"); + return await res.json(); +} + +/** + * Logs in a user with username and password + */ +export async function login(request: LoginRequest): Promise { + const res = await fetch(`${API_BASE_URL}/login`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Login failed" })); + throw new Error(error.detail || "Login failed"); + } + return await res.json(); +} + +/** + * Registers a new user + */ +export async function register(request: RegisterRequest): Promise { + const res = await fetch(`${API_BASE_URL}/register`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Registration failed" })); + throw new Error(error.detail || "Registration failed"); + } + return await res.json(); +} + +/** + * Logs out the current user + */ +export async function logout(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/logout`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to logout"); + return await res.json(); +} + +/** + * Derive a client-side authentication secret so the raw password never leaves the client. + * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. + */ +export async function deriveAuthSecret(username: string, password: string): Promise { + // Use per-user salt derived from username; in future we can fetch a server-provided salt + const salt = new TextEncoder().encode(`fromchat.user:${username}`); + // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword + const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32); + return b64(derived); +} + export async function ensureKeysOnLogin(password: string, token: string): Promise { // Try to restore from backup const blobJson = await fetchBackupBlob(token); @@ -147,13 +178,41 @@ export function getAuthToken(): string | null { } /** - * Derive a client-side authentication secret so the raw password never leaves the client. - * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. + * Changes the user's password */ -export async function deriveAuthSecret(username: string, password: string): Promise { - // Use per-user salt derived from username; in future we can fetch a server-provided salt - const salt = new TextEncoder().encode(`fromchat.user:${username}`); - // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword - const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32); - return b64(derived); -} \ No newline at end of file +export async function changePassword( + token: string, + username: string, + currentPassword: string, + newPassword: string, + logoutAllExceptCurrent: boolean +): Promise { + const currentDerived = await deriveAuthSecret(username, currentPassword); + const newDerived = await deriveAuthSecret(username, newPassword); + const res = await fetch(`${API_BASE_URL}/change-password`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ + currentPasswordDerived: currentDerived, + newPasswordDerived: newDerived, + logoutAllExceptCurrent + }) + }); + if (!res.ok) throw new Error("Failed to change password"); +} + +/** + * Deletes the current user's account + */ +export async function deleteAccount(token: string): Promise<{ status: string; message: string }> { + const res = await fetch(`${API_BASE_URL}/account/delete`, { + method: "POST", + headers: getAuthHeaders(token, true) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to delete account" })); + throw new Error(error.detail || "Failed to delete account"); + } + return await res.json(); +} + diff --git a/frontend/src/core/api/account/profile.ts b/frontend/src/core/api/account/profile.ts new file mode 100644 index 0000000..ddb1fbc --- /dev/null +++ b/frontend/src/core/api/account/profile.ts @@ -0,0 +1,275 @@ +import { getAuthHeaders } from "."; +import { API_BASE_URL } from "@/core/config"; +import type { UserProfile } from "@/core/types"; + +export interface ProfileData { + profile_picture?: string; + username?: string; + display_name?: string; + description?: string; +} + +export interface UploadResponse { + profile_picture_url: string; +} + +/** + * Loads user profile data from the server + */ +export async function loadProfile(token: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + const data = await response.json(); + // Map backend fields to frontend fields + return { + profile_picture: data.profile_picture, + username: data.username, + display_name: data.display_name, + description: data.bio + }; + } + + return null; + } catch (error) { + console.error('Error loading profile:', error); + return null; + } +} + +/** + * Uploads a profile picture to the server + */ +export async function uploadProfilePicture(token: string, file: Blob): Promise { + try { + const formData = new FormData(); + formData.append('profile_picture', file, 'profile_picture.jpg'); + + const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, { + method: 'POST', + body: formData, + headers: getAuthHeaders(token, false) + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Upload error:', error); + return null; + } +} + +/** + * Updates user profile information + */ +export async function updateProfile(token: string, data: Partial): Promise { + try { + // Map frontend fields to backend fields + const backendData = { + username: data.username, + display_name: data.display_name, + description: data.description + }; + + const response = await fetch(`${API_BASE_URL}/user/profile`, { + method: 'PUT', + headers: { + ...getAuthHeaders(token, true), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(backendData) + }); + + return response.ok; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } +} + +/** + * Updates user bio + */ +export async function updateBio(token: string, bio: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/bio`, { + method: 'PUT', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ bio }) + }); + + return response.ok; + } catch (error) { + console.error('Error updating bio:', error); + return false; + } +} + +/** + * Fetches user profile data by username + */ +export async function fetchUserProfile(token: string, username: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/${username}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile:', error); + return null; + } +} + +/** + * Fetches user profile data by user ID + */ +export async function fetchUserProfileById(token: string, userId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile by ID:', error); + return null; + } +} + +/** + * Toggles verification status for a user (owner only) + */ +export async function verifyUser(userId: number, token: string): Promise<{verified: boolean} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error verifying user:', error); + return null; + } +} + +/** + * In-memory cache for user similarity results + * Key: userId, Value: similarity result + */ +const similarityCache = new Map(); + +/** + * Checks if a user is similar to any verified user + * Results are cached in memory to avoid redundant API calls + */ +export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { + // Check cache first + if (similarityCache.has(userId)) { + return similarityCache.get(userId) ?? null; + } + + try { + const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + let result: {isSimilar: boolean, similarTo?: string} | null = null; + if (response.ok) { + result = await response.json(); + } + + // Cache the result (even if null/error) + similarityCache.set(userId, result); + return result; + } catch (error) { + console.error('Error checking user similarity:', error); + const result: null = null; + // Cache null result to avoid retrying on errors + similarityCache.set(userId, result); + return result; + } +} + +/** + * Suspends a user account (admin only) + */ +export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, { + method: 'POST', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error suspending user:', error); + return null; + } +} + +/** + * Unsuspends a user account (admin only) + */ +export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error unsuspending user:', error); + return null; + } +} + +/** + * Deletes a user account (admin only) + */ +export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error deleting user:', error); + return null; + } +} + diff --git a/frontend/src/core/api/calls.ts b/frontend/src/core/api/calls.ts new file mode 100644 index 0000000..e1a726d --- /dev/null +++ b/frontend/src/core/api/calls.ts @@ -0,0 +1,16 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./user/auth"; +import type { IceServersResponse } from "@/core/types"; + +/** + * Fetches ICE server configuration for WebRTC + */ +export async function iceServers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/webrtc/ice`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch ICE servers"); + return await res.json(); +} + + diff --git a/frontend/src/core/api/chats/dm.ts b/frontend/src/core/api/chats/dm.ts new file mode 100644 index 0000000..1feb515 --- /dev/null +++ b/frontend/src/core/api/chats/dm.ts @@ -0,0 +1,153 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import { getCurrentKeys } from "../user/auth"; +import { request } from "@/core/websocket"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, BaseDmEnvelope, User } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; +import { fetchUserPublicKey } from "../crypto/identity"; +import { fetchUsers, searchUsers } from "../user/search"; +import { getOrInitProtocol } from "@/utils/crypto/fromchatInit"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, randomBytes } from "@fromchat/protocol"; + +export async function decrypt(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { + const protocol = getOrInitProtocol(); + const senderPublicKey = ub64(senderPublicKeyB64); + + return await protocol.decryptMessage(senderPublicKey, envelope); +} + +export async function fetchMessages(userId: number, token: string, limit: number = 50, beforeId?: number): Promise<{ messages: DmEnvelope[]; has_more: boolean }> { + let url = `${API_BASE_URL}/dm/history/${userId}?limit=${limit}`; + if (beforeId) { + url += `&before_id=${beforeId}`; + } + const response = await globalThis.fetch(url, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return { messages: [], has_more: false }; + const data = await response.json(); + return { messages: data.messages || [], has_more: data.has_more ?? false }; +} + +export async function send(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { + const protocol = getOrInitProtocol(); + const recipientPublicKey = ub64(recipientPublicKeyB64); + + const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext); + + const payload: SendDMRequest = { + recipientId: recipientId, + ...encrypted + }; + if (replyToId) payload.replyToId = replyToId; + + await request({ + type: "dmSend", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: payload + }); +} + +export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { + // For files, we need to use the same message key for both the message and files + // So we'll do the encryption manually here to reuse the mk + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const wrap = await aesGcmEncrypt(wk, mk); + + const form = new FormData(); + const names: string[] = []; + function sliceBuffer(u8: Uint8Array): ArrayBuffer { + return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + } + + for (const f of files) { + // Encrypt file with same mk + const data = new Uint8Array(await f.arrayBuffer()); + const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); + const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); + const serverName = f.name; + names.push(serverName); + form.append("files", new File([blob], serverName)); + } + form.append("fileNames", JSON.stringify(names)); + + // Encrypt the plaintext JSON with the same mk + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintextJson)); + form.append("dm_payload", JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } satisfies BaseDmEnvelope)); + + await globalThis.fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, false), + body: form + }); +} + +export async function edit(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { + const protocol = getOrInitProtocol(); + const recipientPublicKey = ub64(recipientPublicKeyB64); + + const encrypted = await protocol.encryptMessage(recipientPublicKey, newPlaintextJson); + + await request({ + type: "dmEdit", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { + id, + ...encrypted + } + } as DMEditRequest); +} + +export async function deleteMessage(id: number, recipientId: number, authToken: string): Promise { + await request({ + type: "dmDelete", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id, recipientId } + }); +} + +export interface ConversationResponse { + user: User; + lastMessage: DmEnvelope; + unreadCount: number; +} + +export async function conversations(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/dm/conversations`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.conversations || []; +} + +/** + * Marks a DM as read + */ +export async function markRead(id: number, authToken: string): Promise { + await request({ + type: "dmMarkRead", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id } + }); +} + +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; \ No newline at end of file diff --git a/frontend/src/core/api/chats/general.ts b/frontend/src/core/api/chats/general.ts new file mode 100644 index 0000000..7c31155 --- /dev/null +++ b/frontend/src/core/api/chats/general.ts @@ -0,0 +1,99 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import type { Message, Messages, SendMessageRequest } from "@/core/types"; +import { request } from "@/core/websocket"; + +/** + * Fetches public chat messages + */ +export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise<{ messages: Message[]; has_more: boolean }> { + let url = `${API_BASE_URL}/get_messages?limit=${limit}`; + if (beforeId) { + url += `&before_id=${beforeId}`; + } + const response = await globalThis.fetch(url, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return { messages: [], has_more: false }; + const data: Messages & { has_more?: boolean } = await response.json(); + return { messages: data.messages || [], has_more: data.has_more ?? false }; +} + +/** + * Sends a public chat message via WebSocket + */ +export async function send(content: string, replyToId: number | null, authToken: string): Promise { + await request({ + data: { + content: content.trim(), + reply_to_id: replyToId ?? null + }, + credentials: { + scheme: "Bearer", + credentials: authToken + }, + type: "sendMessage" + } satisfies SendMessageRequest); +} + +/** + * Sends a public chat message with files via HTTP + */ +export async function sendWithFiles( + content: string, + replyToId: number | null, + files: File[], + authToken: string +): Promise { + const form = new FormData(); + form.append("payload", JSON.stringify({ + content: content.trim(), + reply_to_id: replyToId ?? null + } satisfies SendMessageRequest["data"])); + for (const f of files) form.append("files", f, f.name); + const res = await globalThis.fetch(`${API_BASE_URL}/send_message`, { + method: "POST", + headers: getAuthHeaders(authToken, false), + body: form + }); + if (!res.ok) { + const error = await res.text(); + throw new Error(error || "Failed to send message with files"); + } +} + +/** + * Edits a public chat message + */ +export async function edit(messageId: number, newContent: string, authToken: string): Promise { + const res = await globalThis.fetch(`${API_BASE_URL}/edit_message/${messageId}`, { + method: "PUT", + headers: getAuthHeaders(authToken, true), + body: JSON.stringify({ content: newContent }) + }); + if (!res.ok) throw new Error("Failed to edit message"); +} + +/** + * Deletes a public chat message + */ +export async function deleteMessage(messageId: number, authToken: string): Promise { + const res = await globalThis.fetch(`${API_BASE_URL}/delete_message/${messageId}`, { + method: "DELETE", + headers: getAuthHeaders(authToken, true) + }); + if (!res.ok) throw new Error("Failed to delete message"); +} + +/** + * Marks a message as read + */ +export async function markRead(messageId: number, authToken: string): Promise { + const res = await globalThis.fetch(`${API_BASE_URL}/messages/mark_read`, { + method: "POST", + headers: getAuthHeaders(authToken, true), + body: JSON.stringify({ message_id: messageId }) + }); + if (!res.ok) throw new Error("Failed to mark message as read"); +} + diff --git a/frontend/src/core/api/crypto.ts b/frontend/src/core/api/crypto.ts new file mode 100644 index 0000000..ddb668f --- /dev/null +++ b/frontend/src/core/api/crypto.ts @@ -0,0 +1,76 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { UploadPublicKeyRequest, BackupBlob } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; + +/** + * Fetches the current user's public key + */ +export async function fetchPublicKey(token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers }); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.publicKey) return null; + return ub64(data.publicKey); +} + +/** + * Uploads the current user's public key + */ +export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { + const payload: UploadPublicKeyRequest = { + publicKey: b64(publicKey) + } + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload public key"); +} + +/** + * Fetches another user's public key by user ID + */ +export async function fetchUserPublicKey(userId: number, token: string): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return null; + const data = await res.json(); + return data.publicKey; +} + +/** + * Fetches the current user's backup blob + */ +export async function fetchBackupBlob(token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "GET", + headers + }); + if (res.ok) { + const response: BackupBlob = await res.json(); + return response.blob; + } else { + return null; + } +} + +/** + * Uploads the current user's backup blob + */ +export async function uploadBackupBlob(blobJson: string, token: string): Promise { + const payload: BackupBlob = { blob: blobJson } + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload backup blob"); +} + diff --git a/frontend/src/core/api/crypto/backup.ts b/frontend/src/core/api/crypto/backup.ts new file mode 100644 index 0000000..bfa2d54 --- /dev/null +++ b/frontend/src/core/api/crypto/backup.ts @@ -0,0 +1,37 @@ +import { API_BASE_URL } from "@/core/config"; +import type { BackupBlob } from "@/core/types"; +import api from "@/core/api"; + +/** + * Fetches the current user's backup blob + */ +export async function fetchBackupBlob(token: string): Promise { + const headers = api.user.auth.getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "GET", + headers + }); + if (res.ok) { + const response: BackupBlob = await res.json(); + return response.blob; + } else { + return null; + } +} + +/** + * Uploads the current user's backup blob + */ +export async function uploadBackupBlob(blobJson: string, token: string): Promise { + const payload: BackupBlob = { blob: blobJson } + + const headers = api.user.auth.getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/backup`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload backup blob"); +} + + diff --git a/frontend/src/core/api/crypto/identity.ts b/frontend/src/core/api/crypto/identity.ts new file mode 100644 index 0000000..ef02c59 --- /dev/null +++ b/frontend/src/core/api/crypto/identity.ts @@ -0,0 +1,45 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import type { UploadPublicKeyRequest } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; + +/** + * Fetches the current user's public key + */ +export async function fetchPublicKey(token: string): Promise { + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { method: "GET", headers }); + if (!res.ok) return null; + const data = await res.json(); + if (!data?.publicKey) return null; + return ub64(data.publicKey); +} + +/** + * Uploads the current user's public key + */ +export async function uploadPublicKey(publicKey: Uint8Array, token: string): Promise { + const payload: UploadPublicKeyRequest = { + publicKey: b64(publicKey) + } + + const headers = getAuthHeaders(token, true); + const res = await fetch(`${API_BASE_URL}/crypto/public-key`, { + method: "POST", + headers, + body: JSON.stringify(payload) + }); + if (!res.ok) throw new Error("Failed to upload public key"); +} + +/** + * Fetches another user's public key by user ID + */ +export async function fetchUserPublicKey(userId: number, token: string): Promise { + const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return null; + const data = await res.json(); + return data.publicKey; +} + + diff --git a/frontend/src/core/api/crypto/prekeys.ts b/frontend/src/core/api/crypto/prekeys.ts new file mode 100644 index 0000000..3a32ce6 --- /dev/null +++ b/frontend/src/core/api/crypto/prekeys.ts @@ -0,0 +1,14 @@ +// Placeholder for Signal Protocol pre-key management +// Will be implemented when Signal Protocol is added + +export async function upload(_bundle: unknown, _token: string): Promise { + // TODO: Implement Signal Protocol pre-key upload + throw new Error("Not implemented yet"); +} + +export async function fetch(_userId: number, _token: string): Promise { + // TODO: Implement Signal Protocol pre-key fetch + throw new Error("Not implemented yet"); +} + + diff --git a/frontend/src/core/api/dm.ts b/frontend/src/core/api/dm.ts new file mode 100644 index 0000000..7235c81 --- /dev/null +++ b/frontend/src/core/api/dm.ts @@ -0,0 +1,176 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol"; +import { getCurrentKeys } from "./account"; +import { request } from "@/core/websocket"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; +import { b64, ub64 } from "@/utils/utils"; +import { fetchUserPublicKey } from "./crypto"; +import { fetchUsers, searchUsers } from "./users"; + +export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Obtain the key + const shared = ecdhSharedSecret(keys.privateKey, ub64(senderPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, ub64(envelope.salt), new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const mk = await aesGcmDecrypt(wk, ub64(envelope.iv2), ub64(envelope.wrappedMk)); + + // Decrypt + const msg = await aesGcmDecrypt(await importAesGcmKey(mk), ub64(envelope.iv), ub64(envelope.ciphertext)); + return new TextDecoder().decode(msg); +} + +export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { + const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return []; + const data = await response.json(); + return data.messages || []; +} + +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + +export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // Encryption key + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + // Encrypt the message + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(plaintext)); + const wrap = await aesGcmEncrypt(wk, mk); + + const payload: SendDMRequest = { + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + }; + if (replyToId) payload.replyToId = replyToId; + + await request({ + type: "dmSend", + credentials: { + scheme: "Bearer", + credentials: authToken + }, + data: payload + }); +} + +export async function sendDmWithFiles(recipientId: number, recipientPublicKeyB64: string, plaintextJson: string, files: File[], token: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + + const wrap = await aesGcmEncrypt(wk, mk); + + const form = new FormData(); + const names: string[] = []; + function sliceBuffer(u8: Uint8Array): ArrayBuffer { + return (u8.buffer as ArrayBuffer).slice(u8.byteOffset, u8.byteOffset + u8.byteLength); + } + + for (const f of files) { + // Encrypt file with same mk + const data = new Uint8Array(await f.arrayBuffer()); + const enc = await aesGcmEncrypt(await importAesGcmKey(mk), data); + const blob = new Blob([sliceBuffer(enc.iv), sliceBuffer(enc.ciphertext)], { type: "application/octet-stream" }); + const serverName = f.name; // server uses provided name + names.push(serverName); + form.append("files", new File([blob], serverName)); + } + form.append("fileNames", JSON.stringify(names)); + + // Merge files metadata into plaintext JSON and encrypt + let obj: DmEncryptedJSON; + try { + obj = JSON.parse(plaintextJson); + } catch { + obj = { type: "text", data: { content: String(plaintextJson) } }; + } + + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(JSON.stringify(obj))); + form.append("dm_payload", JSON.stringify({ + recipientId: recipientId, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + salt: b64(wkSalt), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext) + } satisfies BaseDmEnvelope)); + + await fetch(`${API_BASE_URL}/dm/send`, { + method: "POST", + headers: getAuthHeaders(token, false), + body: form + }); +} + +export async function editDmEnvelope(id: number, recipientPublicKeyB64: string, newPlaintextJson: string, authToken: string): Promise { + const keys = getCurrentKeys(); + if (!keys) throw new Error("Keys not initialized"); + + // We cannot reuse the old mk safely without knowing it; generate a fresh mk and wrap + const mk = randomBytes(32); + const wkSalt = randomBytes(16); + const shared = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + const wkRaw = await deriveWrappingKey(shared, wkSalt, new Uint8Array([1])); + const wk = await importAesGcmKey(wkRaw); + const encMsg = await aesGcmEncrypt(await importAesGcmKey(mk), new TextEncoder().encode(newPlaintextJson)); + const wrap = await aesGcmEncrypt(wk, mk); + + await request({ + type: "dmEdit", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { + id, + iv: b64(encMsg.iv), + ciphertext: b64(encMsg.ciphertext), + iv2: b64(wrap.iv), + wrappedMk: b64(wrap.ciphertext), + salt: b64(wkSalt) + } + } as DMEditRequest); +} + +export async function deleteDmEnvelope(id: number, recipientId: number, authToken: string): Promise { + await request({ + type: "dmDelete", + credentials: { scheme: "Bearer", credentials: authToken }, + data: { id, recipientId } + }); +} + +export interface DMConversationResponse { + user: User; + lastMessage: DmEnvelope; + unreadCount: number; +} + +export async function fetchDMConversations(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/dm/conversations`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.conversations || []; +} + diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index 632a40f..7235c81 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,12 +1,12 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./authApi"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; -import { getCurrentKeys } from "./authApi"; +import { getAuthHeaders } from "./account"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes } from "@fromchat/protocol"; +import { getCurrentKeys } from "./account"; import { request } from "@/core/websocket"; -import type { SendDMRequest, DmEnvelope, User, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope } from "@/core/types"; +import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; import { b64, ub64 } from "@/utils/utils"; +import { fetchUserPublicKey } from "./crypto"; +import { fetchUsers, searchUsers } from "./users"; export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string): Promise { const keys = getCurrentKeys(); @@ -23,20 +23,6 @@ export async function decryptDm(envelope: DmEnvelope, senderPublicKeyB64: string return new TextDecoder().decode(msg); } -export async function fetchUsers(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) }); - if (!res.ok) return []; - const data = await res.json(); - return data.users || []; -} - -export async function fetchUserPublicKey(userId: number, token: string): Promise { - const res = await fetch(`${API_BASE_URL}/crypto/public-key/of/${userId}`, { headers: getAuthHeaders(token, true) }); - if (!res.ok) return null; - const data = await res.json(); - return data.publicKey; -} - export async function fetchDMHistory(userId: number, token: string, limit: number = 50): Promise { const response = await fetch(`${API_BASE_URL}/dm/history/${userId}?limit=${limit}`, { headers: getAuthHeaders(token, true) @@ -46,6 +32,9 @@ export async function fetchDMHistory(userId: number, token: string, limit: numbe return data.messages || []; } +// Re-export user functions for convenience +export { fetchUsers, searchUsers, fetchUserPublicKey }; + export async function sendDMViaWebSocket(recipientId: number, recipientPublicKeyB64: string, plaintext: string, authToken: string, replyToId?: number): Promise { const keys = getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); @@ -185,13 +174,3 @@ export async function fetchDMConversations(token: string): Promise { - if (query.length < 2) return []; - - const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { - headers: getAuthHeaders(token, true) - }); - if (!res.ok) return []; - const data = await res.json(); - return data.users || []; -} diff --git a/frontend/src/core/api/files.ts b/frontend/src/core/api/files.ts new file mode 100644 index 0000000..c9e8186 --- /dev/null +++ b/frontend/src/core/api/files.ts @@ -0,0 +1,43 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./user/auth"; + +export const normal = { + /** + * Gets the URL for a normal (unencrypted) file + */ + url(filename: string): string { + return `${API_BASE_URL}/uploads/files/normal/${filename}`; + }, + + /** + * Fetches a normal file (unencrypted) + */ + async fetch(filename: string, token: string): Promise { + const res = await fetch(this.url(filename), { + headers: getAuthHeaders(token, false) + }); + if (!res.ok) throw new Error("Failed to fetch file"); + return await res.blob(); + } +}; + +export const encrypted = { + /** + * Gets the URL for an encrypted file + */ + url(filename: string): string { + return `${API_BASE_URL}/uploads/files/encrypted/${filename}`; + }, + + /** + * Fetches an encrypted file + */ + async fetch(filename: string, token: string): Promise { + const res = await fetch(this.url(filename), { + headers: getAuthHeaders(token, false) + }); + if (!res.ok) throw new Error("Failed to fetch encrypted file"); + return await res.blob(); + } +}; + diff --git a/frontend/src/core/api/index.ts b/frontend/src/core/api/index.ts new file mode 100644 index 0000000..dbeb1be --- /dev/null +++ b/frontend/src/core/api/index.ts @@ -0,0 +1,50 @@ +import * as chatsGeneral from "./chats/general"; +import * as chatsDm from "./chats/dm"; +import * as userProfile from "./user/profile"; +import * as userAuth from "./user/auth"; +import * as userDevices from "./user/devices"; +import * as userSearch from "./user/search"; +import * as cryptoPrekeys from "./crypto/prekeys"; +import * as cryptoIdentity from "./crypto/identity"; +import * as cryptoBackup from "./crypto/backup"; +import * as moderationBlocklist from "./moderation/blocklist"; +import * as moderationUsers from "./moderation/users"; +import * as callsModule from "./calls"; +import * as filesModule from "./files"; +import * as pushModule from "./push"; + +const api = { + chats: { + general: chatsGeneral, + dm: chatsDm + }, + user: { + profile: userProfile, + auth: userAuth, + devices: userDevices, + search: userSearch + }, + crypto: { + prekeys: cryptoPrekeys, + identity: cryptoIdentity, + backup: cryptoBackup + }, + moderation: { + blocklist: moderationBlocklist, + users: moderationUsers + }, + calls: callsModule, + files: filesModule, + push: pushModule +}; + +export default api; + +export const chats = api.chats; +export const user = api.user; +export const crypto = api.crypto; +export const moderation = api.moderation; +export const calls = api.calls; +export const files = api.files; +export const push = api.push; + diff --git a/frontend/src/core/api/messaging.ts b/frontend/src/core/api/messaging.ts new file mode 100644 index 0000000..79064a1 --- /dev/null +++ b/frontend/src/core/api/messaging.ts @@ -0,0 +1,116 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { Message, Messages, SendMessageRequest } from "@/core/types"; +import { request } from "@/core/websocket"; + +class HttpError extends Error { + status: number; + detail: string; + + constructor(message: string, status: number, detail: string) { + super(message); + this.name = "HttpError"; + this.status = status; + this.detail = detail; + } +} + +/** + * Fetches public chat messages + */ +export async function fetchMessages(token: string, limit: number = 50, beforeId?: number): Promise { + let url = `${API_BASE_URL}/get_messages?limit=${limit}`; + if (beforeId) { + url += `&before_id=${beforeId}`; + } + const response = await fetch(url, { + headers: getAuthHeaders(token, true) + }); + if (!response.ok) return []; + const data: Messages = await response.json(); + return data.messages || []; +} + +/** + * Sends a public chat message via WebSocket + */ +export async function sendMessage(content: string, replyToId: number | null, authToken: string): Promise { + await request({ + data: { + content: content.trim(), + reply_to_id: replyToId ?? null + }, + credentials: { + scheme: "Bearer", + credentials: authToken + }, + type: "sendMessage" + } satisfies SendMessageRequest); +} + +/** + * Sends a public chat message with files via HTTP + */ +export async function sendMessageWithFiles( + content: string, + replyToId: number | null, + files: File[], + authToken: string +): Promise { + const form = new FormData(); + form.append("payload", JSON.stringify({ + content: content.trim(), + reply_to_id: replyToId ?? null + } satisfies SendMessageRequest["data"])); + for (const f of files) form.append("files", f, f.name); + const res = await fetch(`${API_BASE_URL}/send_message`, { + method: "POST", + headers: getAuthHeaders(authToken, false), + body: form + }); + if (!res.ok) { + let errorDetail = "Failed to send message with files"; + try { + const errorJson = await res.json(); + errorDetail = errorJson.detail || errorDetail; + } catch { + const errorText = await res.text(); + errorDetail = errorText || errorDetail; + } + throw new HttpError(errorDetail, res.status, errorDetail); + } +} + +/** + * Edits a public chat message + */ +export async function editMessage(messageId: number, newContent: string, authToken: string): Promise { + const res = await fetch(`${API_BASE_URL}/edit_message/${messageId}`, { + method: "PUT", + headers: getAuthHeaders(authToken, true), + body: JSON.stringify({ content: newContent }) + }); + if (!res.ok) { + let errorDetail = "Failed to edit message"; + try { + const errorJson = await res.json(); + errorDetail = errorJson.detail || errorDetail; + } catch { + const errorText = await res.text(); + errorDetail = errorText || errorDetail; + } + throw new HttpError(errorDetail, res.status, errorDetail); + } +} + +/** + * Deletes a public chat message + */ +export async function deleteMessage(messageId: number, authToken: string): Promise { + const res = await fetch(`${API_BASE_URL}/delete_message/${messageId}`, { + method: "DELETE", + headers: getAuthHeaders(authToken, true) + }); + if (!res.ok) throw new Error("Failed to delete message"); +} + diff --git a/frontend/src/core/api/moderation.ts b/frontend/src/core/api/moderation.ts new file mode 100644 index 0000000..6786973 --- /dev/null +++ b/frontend/src/core/api/moderation.ts @@ -0,0 +1,54 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +export interface BlocklistResponse { + words: string[]; +} + +export interface BlocklistUpdateRequest { + words: string[]; +} + +export interface BlocklistUpdateResponse { + added?: string[]; + removed?: string[]; + words: string[]; +} + +/** + * Fetches the current blocklist (admin only) + */ +export async function getBlocklist(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch blocklist"); + return await res.json(); +} + +/** + * Adds words to the blocklist (admin only) + */ +export async function addToBlocklist(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to add to blocklist"); + return await res.json(); +} + +/** + * Removes words from the blocklist (admin only) + */ +export async function removeFromBlocklist(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "DELETE", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to remove from blocklist"); + return await res.json(); +} + diff --git a/frontend/src/core/api/moderation/blocklist.ts b/frontend/src/core/api/moderation/blocklist.ts new file mode 100644 index 0000000..9421d5c --- /dev/null +++ b/frontend/src/core/api/moderation/blocklist.ts @@ -0,0 +1,55 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; + +export interface BlocklistResponse { + words: string[]; +} + +export interface BlocklistUpdateRequest { + words: string[]; +} + +export interface BlocklistUpdateResponse { + added?: string[]; + removed?: string[]; + words: string[]; +} + +/** + * Fetches the current blocklist (admin only) + */ +export async function get(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch blocklist"); + return await res.json(); +} + +/** + * Adds words to the blocklist (admin only) + */ +export async function add(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to add to blocklist"); + return await res.json(); +} + +/** + * Removes words from the blocklist (admin only) + */ +export async function remove(words: string[], token: string): Promise { + const res = await fetch(`${API_BASE_URL}/moderation/blocklist`, { + method: "DELETE", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ words }) + }); + if (!res.ok) throw new Error("Failed to remove from blocklist"); + return await res.json(); +} + + diff --git a/frontend/src/core/api/moderation/users.ts b/frontend/src/core/api/moderation/users.ts new file mode 100644 index 0000000..d78f9c1 --- /dev/null +++ b/frontend/src/core/api/moderation/users.ts @@ -0,0 +1,89 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; + +/** + * Toggles verification status for a user (owner only) + */ +export async function verify(userId: number, token: string): Promise<{verified: boolean} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/verify`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error verifying user:', error); + return null; + } +} + +/** + * Suspends a user account (admin only) + */ +export async function suspend(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, { + method: 'POST', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error suspending user:', error); + return null; + } +} + +/** + * Unsuspends a user account (admin only) + */ +export async function unsuspend(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error unsuspending user:', error); + return null; + } +} + +/** + * Deletes a user account (admin only) + */ +export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, { + method: 'POST', + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error deleting user:', error); + return null; + } +} + + diff --git a/frontend/src/core/api/profileApi.ts b/frontend/src/core/api/profileApi.ts index f16f03b..b161756 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -1,4 +1,4 @@ -import { getAuthHeaders } from "./authApi"; +import { getAuthHeaders } from "./account"; import { API_BASE_URL } from "@/core/config"; import type { UserProfile } from "@/core/types"; @@ -171,22 +171,104 @@ export async function verifyUser(userId: number, token: string): Promise<{verifi } } +/** + * In-memory cache for user similarity results + * Key: userId, Value: similarity result + */ +const similarityCache = new Map(); + /** * Checks if a user is similar to any verified user + * Results are cached in memory to avoid redundant API calls */ export async function checkUserSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { + // Check cache first + if (similarityCache.has(userId)) { + return similarityCache.get(userId) ?? null; + } + try { const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { headers: getAuthHeaders(token) }); + let result: {isSimilar: boolean, similarTo?: string} | null = null; + if (response.ok) { + result = await response.json(); + } + + // Cache the result (even if null/error) + similarityCache.set(userId, result); + return result; + } catch (error) { + console.error('Error checking user similarity:', error); + const result: null = null; + // Cache null result to avoid retrying on errors + similarityCache.set(userId, result); + return result; + } +} + +/** + * Suspends a user account (admin only) + */ +export async function suspendUser(userId: number, reason: string, token: string): Promise<{status: string; message: string; reason: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/suspend`, { + method: 'POST', + headers: getAuthHeaders(token), + body: JSON.stringify({ reason }) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error suspending user:', error); + return null; + } +} + +/** + * Unsuspends a user account (admin only) + */ +export async function unsuspendUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/unsuspend`, { + method: 'POST', + headers: getAuthHeaders(token) + }); + if (response.ok) { return await response.json(); } return null; } catch (error) { - console.error('Error checking user similarity:', error); + console.error('Error unsuspending user:', error); + return null; + } +} + +/** + * Deletes a user account (admin only) + */ +export async function deleteUser(userId: number, token: string): Promise<{status: string; message: string} | null> { + try { + const response = await fetch(`${API_BASE_URL}/user/${userId}/delete`, { + method: 'POST', + headers: getAuthHeaders(token) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error deleting user:', error); return null; } } diff --git a/frontend/src/core/api/push.ts b/frontend/src/core/api/push.ts new file mode 100644 index 0000000..31ae7bb --- /dev/null +++ b/frontend/src/core/api/push.ts @@ -0,0 +1,52 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./user/auth"; + +export interface PushSubscriptionRequest { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface PushSubscriptionResponse { + status: string; + message: string; +} + +export const subscription = { + /** + * Subscribes the current user to push notifications + */ + async subscribe( + subscription: PushSubscriptionRequest, + token: string + ): Promise { + const res = await fetch(`${API_BASE_URL}/push/subscribe`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify(subscription) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to subscribe to push notifications" })); + throw new Error(error.detail || "Failed to subscribe to push notifications"); + } + return await res.json(); + }, + + /** + * Unsubscribes the current user from push notifications + */ + async unsubscribe(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/push/unsubscribe`, { + method: "DELETE", + headers: getAuthHeaders(token, true) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to unsubscribe from push notifications" })); + throw new Error(error.detail || "Failed to unsubscribe from push notifications"); + } + return await res.json(); + } +}; + diff --git a/frontend/src/core/api/securityApi.ts b/frontend/src/core/api/securityApi.ts deleted file mode 100644 index a8488b8..0000000 --- a/frontend/src/core/api/securityApi.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders, deriveAuthSecret } from "@/core/api/authApi"; - -export async function changePassword( - token: string, - username: string, - currentPassword: string, - newPassword: string, - logoutAllExceptCurrent: boolean -): Promise { - const currentDerived = await deriveAuthSecret(username, currentPassword); - const newDerived = await deriveAuthSecret(username, newPassword); - const res = await fetch(`${API_BASE_URL}/change-password`, { - method: "POST", - headers: getAuthHeaders(token), - body: JSON.stringify({ - currentPasswordDerived: currentDerived, - newPasswordDerived: newDerived, - logoutAllExceptCurrent - }) - }); - if (!res.ok) throw new Error("Failed to change password"); -} - -export async function deleteAccount(token: string): Promise { - const res = await fetch(`${API_BASE_URL}/account/delete`, { - method: "POST", - headers: getAuthHeaders(token) - }); - if (!res.ok) { - const error = await res.json().catch(() => ({ detail: "Failed to delete account" })); - throw new Error(error.detail || "Failed to delete account"); - } -} - - diff --git a/frontend/src/core/api/user/auth.ts b/frontend/src/core/api/user/auth.ts new file mode 100644 index 0000000..adc656b --- /dev/null +++ b/frontend/src/core/api/user/auth.ts @@ -0,0 +1,219 @@ +import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types"; +import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol"; +import { b64, ub64 } from "@/utils/utils"; +import { fetchPublicKey, uploadPublicKey } from "../crypto/identity"; +import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup"; + +/** + * Generates authentication headers for API requests + * @param {string | null} token - Authentication token + * @param {boolean} json - Whether to include JSON content type header + * @returns {Headers} Headers object with authentication and content type + */ +export function getAuthHeaders(token: string | null, json: boolean = true): Headers { + const headers: Headers = {}; + + if (json) { + headers["Content-Type"] = "application/json"; + } + + if (token) { + headers['Authorization'] = `Bearer ${token}`; + } + return headers; +} + +export interface CheckAuthResponse { + authenticated: boolean; + username: string; + admin: boolean; +} + +export interface LogoutResponse { + status: string; + message: string; +} + +export interface UserKeyPairMemory { + publicKey: Uint8Array; + privateKey: Uint8Array; +} + +let currentPublicKey: Uint8Array | null = null; +let currentPrivateKey: Uint8Array | null = null; + +export function getCurrentKeys(): UserKeyPairMemory | null { + if (currentPublicKey && currentPrivateKey) return { publicKey: currentPublicKey, privateKey: currentPrivateKey }; + return null; +} + +function saveKeys( + publicKey: Uint8Array, + privateKey: Uint8Array +) { + const encodedPublicKey = b64(publicKey); + const encodedPrivateKey = b64(privateKey); + + localStorage.setItem("publicKey", encodedPublicKey); + localStorage.setItem("privateKey", encodedPrivateKey); +} + +/** + * Checks if the current user is authenticated + */ +export async function checkAuth(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/check_auth`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to check auth"); + return await res.json(); +} + +/** + * Logs in a user with username and password + */ +export async function login(request: LoginRequest): Promise { + const res = await fetch(`${API_BASE_URL}/login`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Login failed" })); + throw new Error(error.detail || "Login failed"); + } + return await res.json(); +} + +/** + * Registers a new user + */ +export async function register(request: RegisterRequest): Promise { + const res = await fetch(`${API_BASE_URL}/register`, { + method: "POST", + headers: getAuthHeaders(null, true), + body: JSON.stringify(request) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Registration failed" })); + throw new Error(error.detail || "Registration failed"); + } + return await res.json(); +} + +/** + * Logs out the current user + */ +export async function logout(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/logout`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to logout"); + return await res.json(); +} + +/** + * Derive a client-side authentication secret so the raw password never leaves the client. + * Uses PBKDF2 (via WebCrypto) + HKDF to produce a stable 32-byte key, then base64. + */ +export async function deriveAuthSecret(username: string, password: string): Promise { + // Use per-user salt derived from username; in future we can fetch a server-provided salt + const salt = new TextEncoder().encode(`fromchat.user:${username}`); + // Derive 32 bytes using HKDF; PBKDF2 already used within importPassword + const derived = await hkdfExtractAndExpand(new TextEncoder().encode(password), salt, new TextEncoder().encode("auth-secret"), 32); + return b64(derived); +} + +export async function ensureKeysOnLogin(password: string, token: string): Promise { + // Try to restore from backup + const blobJson = await fetchBackupBlob(token); + if (blobJson) { + const blob = decodeBlob(blobJson); + const bundle = await decryptBackupWithPassword(password, blob); + currentPrivateKey = bundle.privateKey; + // Ensure public key exists on server; if not, derive from private (not possible via libsafely), so keep previous + // In our simple scheme, we rely on server having the public key or we reupload generated one on first setup + const serverPub = await fetchPublicKey(token); + if (serverPub) { + currentPublicKey = serverPub; + } else { + // We don't have the corresponding public key from server; regenerate pair to resync + const pair = generateX25519KeyPair(); + currentPublicKey = pair.publicKey; + currentPrivateKey = pair.privateKey; + await uploadPublicKey(currentPublicKey, token); + const newBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(newBlob), token); + } + + saveKeys(currentPublicKey!, currentPrivateKey!); + + return { + publicKey: currentPublicKey!, + privateKey: currentPrivateKey! + }; + } + + // First-time setup: generate keys and upload + const pair = generateX25519KeyPair(); + currentPublicKey = pair.publicKey; + currentPrivateKey = pair.privateKey; + await uploadPublicKey(currentPublicKey, token); + const encBlob = await encryptBackupWithPassword(password, { version: 1, privateKey: currentPrivateKey }); + await uploadBackupBlob(encodeBlob(encBlob), token); + + saveKeys(pair.publicKey, pair.privateKey); + + return pair; +} + +export function restoreKeys() { + currentPublicKey = ub64(localStorage.getItem("publicKey")!); + currentPrivateKey = ub64(localStorage.getItem("privateKey")!); +} + +export function getAuthToken(): string | null { + return localStorage.getItem("authToken"); +} + +/** + * Changes the user's password + */ +export async function changePassword( + token: string, + username: string, + currentPassword: string, + newPassword: string, + logoutAllExceptCurrent: boolean +): Promise { + const currentDerived = await deriveAuthSecret(username, currentPassword); + const newDerived = await deriveAuthSecret(username, newPassword); + const res = await fetch(`${API_BASE_URL}/change-password`, { + method: "POST", + headers: getAuthHeaders(token, true), + body: JSON.stringify({ + currentPasswordDerived: currentDerived, + newPasswordDerived: newDerived, + logoutAllExceptCurrent + }) + }); + if (!res.ok) throw new Error("Failed to change password"); +} + +/** + * Deletes the current user's account + */ +export async function deleteAccount(token: string): Promise<{ status: string; message: string }> { + const res = await fetch(`${API_BASE_URL}/account/delete`, { + method: "POST", + headers: getAuthHeaders(token, true) + }); + if (!res.ok) { + const error = await res.json().catch(() => ({ detail: "Failed to delete account" })); + throw new Error(error.detail || "Failed to delete account"); + } + return await res.json(); +} + + diff --git a/frontend/src/core/api/user/devices.ts b/frontend/src/core/api/user/devices.ts new file mode 100644 index 0000000..b9844e5 --- /dev/null +++ b/frontend/src/core/api/user/devices.ts @@ -0,0 +1,37 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./auth"; + +export interface DeviceInfo { + session_id: string; + device_name?: string; + device_type?: string; + os_name?: string; + os_version?: string; + browser_name?: string; + browser_version?: string; + brand?: string; + model?: string; + created_at?: string; + last_seen?: string; + revoked?: boolean; + current?: boolean; +} + +export async function list(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) throw new Error("Failed to fetch devices"); + const data = await res.json(); + return data.devices as DeviceInfo[]; +} + +export async function revoke(token: string, sessionId: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices/${sessionId}`, { method: "DELETE", headers: getAuthHeaders(token, true) }); + if (!res.ok) throw new Error("Failed to revoke device"); +} + +export async function revokeAll(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/devices/logout-all`, { method: "POST", headers: getAuthHeaders(token, true) }); + if (!res.ok) throw new Error("Failed to logout all devices"); +} + + diff --git a/frontend/src/core/api/user/profile.ts b/frontend/src/core/api/user/profile.ts new file mode 100644 index 0000000..ce3be53 --- /dev/null +++ b/frontend/src/core/api/user/profile.ts @@ -0,0 +1,191 @@ +import { getAuthHeaders } from "./auth"; +import { API_BASE_URL } from "@/core/config"; +import type { UserProfile } from "@/core/types"; + +export interface ProfileData { + profile_picture?: string; + username?: string; + display_name?: string; + description?: string; +} + +export interface UploadResponse { + profile_picture_url: string; +} + +/** + * Loads user profile data from the server + */ +export async function get(token: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + const data = await response.json(); + // Map backend fields to frontend fields + return { + profile_picture: data.profile_picture, + username: data.username, + display_name: data.display_name, + description: data.bio + }; + } + + return null; + } catch (error) { + console.error('Error loading profile:', error); + return null; + } +} + +/** + * Uploads a profile picture to the server + */ +export async function uploadPicture(token: string, file: Blob): Promise { + try { + const formData = new FormData(); + formData.append('profile_picture', file, 'profile_picture.jpg'); + + const response = await fetch(`${API_BASE_URL}/upload-profile-picture`, { + method: 'POST', + body: formData, + headers: getAuthHeaders(token, false) + }); + + if (response.ok) { + return await response.json(); + } + return null; + } catch (error) { + console.error('Upload error:', error); + return null; + } +} + +/** + * Updates user profile information + */ +export async function update(token: string, data: Partial): Promise { + try { + // Map frontend fields to backend fields + const backendData = { + username: data.username, + display_name: data.display_name, + description: data.description + }; + + const response = await fetch(`${API_BASE_URL}/user/profile`, { + method: 'PUT', + headers: { + ...getAuthHeaders(token, true), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(backendData) + }); + + return response.ok; + } catch (error) { + console.error('Error updating profile:', error); + return false; + } +} + +/** + * Updates user bio + */ +export async function updateBio(token: string, bio: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/bio`, { + method: 'PUT', + headers: getAuthHeaders(token, true), + body: JSON.stringify({ bio }) + }); + + return response.ok; + } catch (error) { + console.error('Error updating bio:', error); + return false; + } +} + +/** + * Fetches user profile data by username + */ +export async function fetchByUsername(token: string, username: string): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/${username}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile:', error); + return null; + } +} + +/** + * Fetches user profile data by user ID + */ +export async function fetchById(token: string, userId: number): Promise { + try { + const response = await fetch(`${API_BASE_URL}/user/id/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + if (response.ok) { + return await response.json(); + } + + return null; + } catch (error) { + console.error('Error fetching user profile by ID:', error); + return null; + } +} + +/** + * In-memory cache for user similarity results + * Key: userId, Value: similarity result + */ +const similarityCache = new Map(); + +/** + * Checks if a user is similar to any verified user + * Results are cached in memory to avoid redundant API calls + */ +export async function checkSimilarity(userId: number, token: string): Promise<{isSimilar: boolean, similarTo?: string} | null> { + // Check cache first + if (similarityCache.has(userId)) { + return similarityCache.get(userId) ?? null; + } + + try { + const response = await fetch(`${API_BASE_URL}/user/check-similarity/${userId}`, { + headers: getAuthHeaders(token, true) + }); + + let result: {isSimilar: boolean, similarTo?: string} | null = null; + if (response.ok) { + result = await response.json(); + } + + // Cache the result (even if null/error) + similarityCache.set(userId, result); + return result; + } catch (error) { + console.error('Error checking user similarity:', error); + const result: null = null; + // Cache null result to avoid retrying on errors + similarityCache.set(userId, result); + return result; + } +} + + diff --git a/frontend/src/core/api/user/search.ts b/frontend/src/core/api/user/search.ts new file mode 100644 index 0000000..6285506 --- /dev/null +++ b/frontend/src/core/api/user/search.ts @@ -0,0 +1,40 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./auth"; +import type { User } from "@/core/types"; + +/** + * Fetches a list of all users (excluding current user) + */ +export async function fetchUsers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + +/** + * Searches for users by username query + */ +export async function searchUsers(query: string, token: string): Promise { + if (query.length < 2) return []; + + const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + +/** + * Fetches a user by ID + */ +export async function get(userId: number, token: string): Promise { + const res = await fetch(`${API_BASE_URL}/users/${userId}`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return null; + return await res.json(); +} + + diff --git a/frontend/src/core/api/users.ts b/frontend/src/core/api/users.ts new file mode 100644 index 0000000..31dcb2c --- /dev/null +++ b/frontend/src/core/api/users.ts @@ -0,0 +1,28 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { User } from "@/core/types"; + +/** + * Fetches a list of all users (excluding current user) + */ +export async function fetchUsers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/users`, { headers: getAuthHeaders(token, true) }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + +/** + * Searches for users by username query + */ +export async function searchUsers(query: string, token: string): Promise { + if (query.length < 2) return []; + + const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) return []; + const data = await res.json(); + return data.users || []; +} + diff --git a/frontend/src/core/api/webrtc.ts b/frontend/src/core/api/webrtc.ts new file mode 100644 index 0000000..81f7ff7 --- /dev/null +++ b/frontend/src/core/api/webrtc.ts @@ -0,0 +1,15 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import type { IceServersResponse } from "@/core/types"; + +/** + * Fetches ICE server configuration for WebRTC + */ +export async function getIceServers(token: string): Promise { + const res = await fetch(`${API_BASE_URL}/webrtc/ice`, { + headers: getAuthHeaders(token, true) + }); + if (!res.ok) throw new Error("Failed to fetch ICE servers"); + return await res.json(); +} + diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index 5c0c926..cf61801 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -1,8 +1,6 @@ -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt, randomBytes, ecdhSharedSecret, deriveWrappingKey } from "@fromchat/protocol"; import { b64, ub64 } from "@/utils/utils"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { getCurrentKeys } from "@/core/api/authApi"; +import api from "@/core/api"; import type { WrappedSessionKeyPayload } from "@/core/types"; export interface CallSessionKey { @@ -186,7 +184,7 @@ const CALL_INFO = new Uint8Array([2]); * @returns Promise that resolves to the wrapped session key payload */ export async function wrapCallSessionKeyForRecipient(recipientPublicKeyB64: string, sessionKey: Uint8Array): Promise { - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); const salt = randomBytes(16); @@ -209,7 +207,7 @@ export async function createSharedSecretAndDeriveSessionKey( sessionKeyHash: string, isInitiator: boolean ): Promise { - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); // Create shared secret using ECDH @@ -226,7 +224,7 @@ export async function createSharedSecretAndDeriveSessionKey( * @returns Promise that resolves to the unwrapped session key */ export async function unwrapCallSessionKeyFromSender(senderPublicKeyB64: string, payload: WrappedSessionKeyPayload): Promise { - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); const salt = ub64(payload.salt); diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 1680908..1edc0f8 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,9 +1,8 @@ -import { getAuthHeaders, getAuthToken } from "@/core/api/authApi"; -import type { CallSignalingMessage, IceServersResponse, WrappedSessionKeyPayload } from "@/core/types"; +import api from "@/core/api"; +import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; -import { fetchUserPublicKey } from "@/core/api/dmApi"; -import { importAesGcmKey } from "@/utils/crypto/symmetric"; +import { importAesGcmKey } from "@fromchat/protocol"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; @@ -99,16 +98,10 @@ export class WebRTCCall { */ private async getIceServers(): Promise { try { - const response = await fetch("/api/webrtc/ice", { - headers: getAuthHeaders(getAuthToken()!) - }); - - if (response.ok) { - const data = await response.json() as IceServersResponse; - return data.iceServers || []; - } else { - console.warn("Failed to fetch ICE servers:", response.status, response.statusText); - } + const token = api.user.auth.getAuthToken(); + if (!token) throw new Error("No auth token"); + const data = await api.calls.iceServers(token); + return data.iceServers || []; } catch (error) { console.warn("Failed to fetch ICE servers:", error); } @@ -779,7 +772,7 @@ async function sendSignalingMessage(message: CallSignalingMessage) { type: "call_signaling", credentials: { scheme: "Bearer", - credentials: getAuthToken()! + credentials: api.user.auth.getAuthToken()! }, data: message }); @@ -862,7 +855,7 @@ export async function sendCallSessionKey(userId: number, sessionKeyHash: string) export async function sendWrappedCallSessionKey(userId: number, sessionKey: Uint8Array, sessionKeyHash: string): Promise { try { - const recipientPublicKey = await fetchUserPublicKey(userId, getAuthToken()!); + const recipientPublicKey = await api.chats.dm.fetchUserPublicKey(userId, api.user.auth.getAuthToken()!); if (!recipientPublicKey) { console.warn("No recipient public key for", userId); return; @@ -897,7 +890,7 @@ export async function receiveWrappedSessionKey( sessionKeyHash?: string ): Promise { try { - const senderPublicKey = await fetchUserPublicKey(fromUserId, getAuthToken()!); + const senderPublicKey = await api.chats.dm.fetchUserPublicKey(fromUserId, api.user.auth.getAuthToken()!); if (!senderPublicKey) { console.error("Failed to get sender public key"); return; diff --git a/frontend/src/core/components/AlertDialog.tsx b/frontend/src/core/components/AlertDialog.tsx new file mode 100644 index 0000000..a6ecabc --- /dev/null +++ b/frontend/src/core/components/AlertDialog.tsx @@ -0,0 +1,94 @@ +import { useState, useCallback, useEffect } from "react"; +import { StyledDialog } from "./StyledDialog"; +import { MaterialButton } from "@/utils/material"; +import styles from "./css/alert-dialog.module.scss"; + +interface AlertDialogState { + open: boolean; + message: string; + resolve: (() => void) | null; +} + +let alertState: AlertDialogState = { + open: false, + message: "", + resolve: null +}; + +const listeners = new Set<() => void>(); + +function notifyListeners() { + listeners.forEach(listener => listener()); +} + +/** + * Drop-in replacement for window.alert() using StyledDialog + * @param message - The message to display + * @returns Promise that resolves when the dialog is closed + */ +export function alert(message: string): Promise { + return new Promise((resolve) => { + alertState = { + open: true, + message, + resolve: () => { + alertState.open = false; + alertState.message = ""; + alertState.resolve = null; + notifyListeners(); + resolve(); + } + }; + notifyListeners(); + }); +} + +/** + * Internal component that renders the alert dialog + */ +export function AlertDialogProvider() { + const [, setUpdateKey] = useState(0); + + const update = useCallback(() => { + setUpdateKey(prev => prev + 1); + }, []); + + useEffect(() => { + listeners.add(update); + return () => { + listeners.delete(update); + }; + }, [update]); + + const handleClose = () => { + if (alertState.resolve) { + alertState.resolve(); + } + }; + + return ( + { + if (!open) { + handleClose(); + } + }} + onBackdropClick={handleClose} + className={styles.alertDialog} + contentClassName={styles.alertDialogContent} + > +
+ {alertState.message} +
+
+ + OK + +
+
+ ); +} diff --git a/frontend/src/core/components/SearchBar.tsx b/frontend/src/core/components/SearchBar.tsx index 8616475..5c59b09 100644 --- a/frontend/src/core/components/SearchBar.tsx +++ b/frontend/src/core/components/SearchBar.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import styles from "./css/searchBar.module.scss"; -import { MaterialIcon } from "@/utils/material"; +import { MaterialIcon, type MDUIBottomAppBar } from "@/utils/material"; interface SearchBarProps { placeholder: string; @@ -11,6 +11,9 @@ interface SearchBarProps { onToggleExpanded: () => void; leftIcon?: string | React.ReactNode; rightIcon?: string | React.ReactNode; + containerRef: React.RefObject; + headerRef?: React.RefObject; + bottomAppBarRef?: React.RefObject; } export default function SearchBar({ @@ -21,9 +24,14 @@ export default function SearchBar({ isExpanded, onToggleExpanded, leftIcon = "search--outlined", - rightIcon = null + rightIcon = null, + containerRef, + headerRef, + bottomAppBarRef }: SearchBarProps) { const [dynamicHeight, setDynamicHeight] = useState("48px"); + const [isTransitioning, setIsTransitioning] = useState(false); + const [showResults, setShowResults] = useState(false); const searchContainerRef = useRef(null); const inputRef = useRef(null); const parentContainerRef = useRef(null); @@ -33,17 +41,46 @@ export default function SearchBar({ useEffect(() => { if (isExpanded && inputRef.current) { inputRef.current.focus(); - // Set expanded height - const leftPanel = document.getElementById('chat-list'); - if (leftPanel) { - const panelHeight = leftPanel.offsetHeight; - setDynamicHeight(`${panelHeight}px`); + // Set expanded height, subtracting both header and bottom app bar heights + if (containerRef.current) { + const panelHeight = containerRef.current.offsetHeight; + let headerHeight = 0; + let bottomBarHeight = 0; + + // Get header height + if (headerRef?.current) { + headerHeight = headerRef.current.offsetHeight; + } + + // Get bottom app bar height + if (bottomAppBarRef?.current) { + bottomBarHeight = bottomAppBarRef.current.offsetHeight; + } + + // Calculate height by subtracting both header and bottom bar heights + const availableHeight = panelHeight - headerHeight - bottomBarHeight; + setDynamicHeight(`${availableHeight}px`); } } else { // Set collapsed height setDynamicHeight("48px"); } - }, [isExpanded]); + + // Show/hide results and disable overflow during transition + if (isExpanded) { + setShowResults(true); + } + + setIsTransitioning(true); + const timeout = setTimeout(() => { + setIsTransitioning(false); + if (!isExpanded) { + setShowResults(false); + } + }, 400); // Match transition duration (0.4s) + + return () => clearTimeout(timeout); + }, [isExpanded, containerRef, headerRef, bottomAppBarRef]); function handleToggle() { onToggleExpanded(); @@ -107,9 +144,12 @@ export default function SearchBar({
- {/* Results Section - Only visible when expanded */} - {isExpanded && ( -
+ {/* Results Section - Visible during expansion and collapse transition */} + {showResults && ( +
{children}
)} diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index bce9df8..9ec282d 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,6 +1,6 @@ import { useState, useEffect } from "react"; -import { checkUserSimilarity } from "@/core/api/profileApi"; -import { useAppState } from "@/pages/chat/state"; +import api from "@/core/api"; +import { useUserStore } from "@/state/user"; import { MaterialIcon } from "@/utils/material"; interface StatusBadgeProps { @@ -11,14 +11,14 @@ interface StatusBadgeProps { export function StatusBadge({ verified, userId, size = "small" }: StatusBadgeProps) { const [isSimilarToVerified, setIsSimilarToVerified] = useState(false); - const { user } = useAppState(); + const { user } = useUserStore(); const className = `status-badge ${size}`; // Check similarity for unverified users useEffect(() => { if (!verified && userId && user.authToken) { - checkUserSimilarity(userId, user.authToken) + api.user.profile.checkSimilarity(userId, user.authToken) .then(result => { setIsSimilarToVerified(result?.isSimilar || false); }) diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 07ed3de..391b8e1 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -1,6 +1,6 @@ import { useState } from "react"; -import { verifyUser } from "@/core/api/profileApi"; -import { useAppState } from "@/pages/chat/state"; +import api from "@/core/api"; +import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; interface VerifyButtonProps { @@ -11,7 +11,7 @@ interface VerifyButtonProps { export function VerifyButton({ userId, verified, onVerificationChange }: VerifyButtonProps) { const [isVerifying, setIsVerifying] = useState(false); - const { user } = useAppState(); + const { user } = useUserStore(); // Only show for owner if (user.currentUser?.id !== 1) { @@ -23,7 +23,7 @@ export function VerifyButton({ userId, verified, onVerificationChange }: VerifyB setIsVerifying(true); try { - const result = await verifyUser(userId, user.authToken); + const result = await api.moderation.users.verify(userId, user.authToken); if (result) { onVerificationChange?.(result.verified); } diff --git a/frontend/src/core/components/css/alert-dialog.module.scss b/frontend/src/core/components/css/alert-dialog.module.scss new file mode 100644 index 0000000..4fd9bd1 --- /dev/null +++ b/frontend/src/core/components/css/alert-dialog.module.scss @@ -0,0 +1,25 @@ +@use "../../../css/colors" as *; +@use "../../../css/material" as *; + +.alertDialog { + .alertDialogContent { + padding: 24px; + display: flex; + flex-direction: column; + gap: 20px; + } + + .alertDialogMessage { + color: $color-dark-on-surface; + font-size: 16px; + line-height: 1.5; + word-wrap: break-word; + } + + .alertDialogActions { + display: flex; + justify-content: flex-end; + gap: 12px; + } +} + diff --git a/frontend/src/core/components/css/searchBar.module.scss b/frontend/src/core/components/css/searchBar.module.scss index a63d859..7f6f049 100644 --- a/frontend/src/core/components/css/searchBar.module.scss +++ b/frontend/src/core/components/css/searchBar.module.scss @@ -42,7 +42,7 @@ $font-size: 16px; top: 0; left: 0; right: 0; - bottom: 0; + // bottom will be set dynamically by React to account for bottom app bar border-radius: 0; background-color: $color-dark-surface-container; // Height will be set dynamically by React diff --git a/frontend/src/core/onlineStatusManager.ts b/frontend/src/core/onlineStatusManager.ts index 6f2525a..fe05253 100644 --- a/frontend/src/core/onlineStatusManager.ts +++ b/frontend/src/core/onlineStatusManager.ts @@ -11,7 +11,7 @@ import type { SubscribeStatusWebSocketMessage, UnsubscribeStatusWebSocketMessage } from "./types"; -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; export interface UserStatus { online: boolean; @@ -96,7 +96,7 @@ export class OnlineStatusManager { this.statusCache.set(userId, { online, lastSeen }); // Update the global state - const { updateOnlineStatus } = useAppState.getState(); + const { updateOnlineStatus } = usePresenceStore.getState(); updateOnlineStatus(userId, online, lastSeen); } diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index bef62c9..7ca24cb 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -1,8 +1,9 @@ -import { API_BASE_URL } from "@/core/config"; +import api from "@/core/api"; import { isElectron } from "@/core/electron/electron"; import { websocket } from "@/core/websocket"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; import serviceWorker from "./service-worker?worker&url"; +import logo from "@/images/logo.svg"; export interface PushSubscriptionData { endpoint: string; @@ -88,16 +89,8 @@ async function sendSubscriptionToServer(token: string): Promise { }; try { - const response = await fetch(`${API_BASE_URL}/push/subscribe`, { - method: "POST", - headers: { - "Content-Type": "application/json", - "Authorization": `Bearer ${token}` - }, - body: JSON.stringify(subscriptionData) - }); - - return response.ok; + await api.push.subscription.subscribe(subscriptionData, token); + return true; } catch (error) { console.error("Failed to send subscription to server:", error); return false; @@ -111,7 +104,7 @@ async function showMessageNotification(message: any): Promise { body: message.content.length > 100 ? message.content.substring(0, 100) + "..." : message.content, - icon: message.profile_picture || "/logo.png", + icon: message.profile_picture || logo, tag: `message_${message.id}`, data: { type: "public_message", diff --git a/frontend/src/core/push-notifications/service-worker.ts b/frontend/src/core/push-notifications/service-worker.ts index ff40735..b111c16 100644 --- a/frontend/src/core/push-notifications/service-worker.ts +++ b/frontend/src/core/push-notifications/service-worker.ts @@ -1,5 +1,7 @@ /// +import logo from "@/images/logo.svg"; + declare const self: ServiceWorkerGlobalScope; interface NotificationPayload { @@ -36,8 +38,8 @@ self.addEventListener("push", function(event: ExtendableEvent) { const options: NotificationOptions = { body: data.body, - icon: data.icon || "/logo.png", - badge: "/logo.png", + icon: data.icon || logo, + badge: logo, image: data.image, tag: data.tag || "message", data: data.data, diff --git a/frontend/src/core/typingManager.ts b/frontend/src/core/typingManager.ts index da5b119..740c800 100644 --- a/frontend/src/core/typingManager.ts +++ b/frontend/src/core/typingManager.ts @@ -16,7 +16,7 @@ import type { DmTypingRequest, StopDmTypingRequest } from "./types"; -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; /** * Manages typing indicators for public chat and DMs @@ -133,7 +133,7 @@ export class TypingManager { * Handle incoming typing indicator from WebSocket */ handleTyping(message: TypingWebSocketMessage): void { - const { addTypingUser } = useAppState.getState(); + const { addTypingUser } = usePresenceStore.getState(); addTypingUser(message.data.userId, message.data.username); } @@ -141,7 +141,7 @@ export class TypingManager { * Handle incoming stop typing indicator from WebSocket */ handleStopTyping(message: StopTypingWebSocketMessage): void { - const { removeTypingUser } = useAppState.getState(); + const { removeTypingUser } = usePresenceStore.getState(); removeTypingUser(message.data.userId); } @@ -149,7 +149,7 @@ export class TypingManager { * Handle incoming DM typing indicator from WebSocket */ handleDmTyping(message: DmTypingWebSocketMessage): void { - const { setDmTypingUser } = useAppState.getState(); + const { setDmTypingUser } = usePresenceStore.getState(); setDmTypingUser(message.data.userId, true); } @@ -157,7 +157,7 @@ export class TypingManager { * Handle incoming stop DM typing indicator from WebSocket */ handleStopDmTyping(message: StopDmTypingWebSocketMessage): void { - const { setDmTypingUser } = useAppState.getState(); + const { setDmTypingUser } = usePresenceStore.getState(); setDmTypingUser(message.data.userId, false); } diff --git a/frontend/src/core/updateManager.ts b/frontend/src/core/updateManager.ts new file mode 100644 index 0000000..ae89c32 --- /dev/null +++ b/frontend/src/core/updateManager.ts @@ -0,0 +1,126 @@ +/** + * @fileoverview Update Manager for Telegram-like update system + * @description Handles update sequence numbers, batching, and gap detection + * @author Cursor + * @version 1.0.0 + */ + +import { openDB, type IDBPDatabase } from "idb"; +import type { WebSocketCredentials, WebSocketMessage } from "./types"; + +interface UpdateMessage { + type: string; + data: T; +} + +interface BatchedUpdatesMessage { + type: "updates"; + seq: number; + updates: UpdateMessage[]; +} + +const DB_NAME = "fromchat-updates"; +const DB_VERSION = 1; +const STORE_NAME = "lastSequence"; + +let db: IDBPDatabase | null = null; + +/** + * Initialize IndexedDB for storing last sequence number + */ +async function initDB(): Promise { + if (db) return db; + + db = await openDB(DB_NAME, DB_VERSION, { + upgrade(database) { + if (!database.objectStoreNames.contains(STORE_NAME)) { + database.createObjectStore(STORE_NAME); + } + } + }); + + return db; +} + +/** + * Get the last received sequence number from IndexedDB + */ +export async function getLastSequence(): Promise { + try { + return (await initDB()) + .transaction(STORE_NAME, "readonly") + .objectStore(STORE_NAME) + .get("lastSeq") || 0; + } catch (error) { + console.error("Failed to get last sequence:", error); + return 0; + } +} + +/** + * Store the last received sequence number in IndexedDB + */ +export async function setLastSequence(seq: number): Promise { + try { + (await initDB()).transaction(STORE_NAME, "readwrite").objectStore(STORE_NAME).put(seq, "lastSeq"); + } catch (error) { + console.error("Failed to set last sequence:", error); + } +} + +/** + * Process a batched updates message + * @param message - The batched updates message from the server + * @param handler - Function to handle individual updates + * @param requestMissedFn - Optional function to request missed updates (for gap detection) + */ +export async function processBatchedUpdates( + message: BatchedUpdatesMessage, + handler: (update: UpdateMessage) => void, + requestMissedFn?: (lastSeq: number) => Promise +): Promise { + const { seq, updates } = message; + const lastSeq = await getLastSequence(); + + // Check for gap + if (seq !== lastSeq + 1 && lastSeq > 0) { + console.warn(`Update gap detected: expected ${lastSeq + 1}, got ${seq}`); + + // Request missing updates if function provided + if (requestMissedFn) { + try { + await requestMissedFn(lastSeq); + } catch (error) { + console.error("Failed to request missed updates for gap:", error); + } + } + } + + // Process all updates in the batch + for (const update of updates) { + handler(update); + } + + // Update last sequence number + await setLastSequence(seq); +} + +/** + * Request missed updates from the server + * @param lastSeq - The last sequence number we received + * @param requestFn - Function to send the request to the server + * @param credentials - Optional WebSocket credentials for authentication + */ +export async function requestMissedUpdates( + lastSeq: number, + requestFn: (request: WebSocketMessage<{ lastSeq: number }>) => Promise, + credentials?: WebSocketCredentials +): Promise { + if (lastSeq > 0) { + await requestFn({ + type: "getUpdates", + data: { lastSeq }, + credentials + }); + } +} \ No newline at end of file diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 435a113..1ea1689 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -11,7 +11,14 @@ import { delay } from "@/utils/utils"; import { CallSignalingHandler } from "./calls/signaling"; import { onlineStatusManager } from "./onlineStatusManager"; import { typingManager } from "./typingManager"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager"; +import { getAuthToken } from "@/core/api/user/auth"; + +interface HttpError extends Error { + status?: number; + detail?: string; +} /** * Creates a new WebSocket connection to the chat server @@ -44,6 +51,18 @@ let globalMessageHandler: ((response: WebSocketMessage) => void) | null = n */ let callSignalingHandler: CallSignalingHandler | null = null; +/** + * Reconnection state + */ +let reconnectAttempts = 0; +const MAX_RECONNECT_DELAY = 30000; // 30 seconds max delay +const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second +let isReconnecting = false; +let messageHandler: ((e: MessageEvent) => void) | null = null; +let errorHandler: ((e: Event) => void) | null = null; +let closeHandler: ((e: CloseEvent) => void) | null = null; +let openHandler: ((e: Event) => void) | null = null; + /** * Set the global WebSocket message handler * @param handler - Function to handle WebSocket messages @@ -60,66 +79,120 @@ export function setCallSignalingHandler(handler: CallSignalingHandler | null): v callSignalingHandler = handler; } -export function request(payload: WebSocketMessage): Promise> { - console.log("WebSocket request:", payload); - return new Promise((resolve, reject) => { - function requestInner() { - let listener: ((e: MessageEvent) => void) | null = null; - listener = (e) => { - resolve(JSON.parse(e.data)); - websocket.removeEventListener("message", listener!); +/** + * Clean up all event listeners from the current WebSocket instance + * @private + */ +function cleanupWebSocket(): void { + if (websocket) { + if (messageHandler) { + websocket.removeEventListener("message", messageHandler); + } + if (errorHandler) { + websocket.removeEventListener("error", errorHandler); + } + if (closeHandler) { + websocket.removeEventListener("close", closeHandler); + } + if (openHandler) { + websocket.removeEventListener("open", openHandler); + } + + // Close if still connected + if (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) { + try { + websocket.close(); + } catch (e) { + // Ignore errors during cleanup } - websocket.addEventListener("message", listener); - websocket.send(JSON.stringify(payload)) - - setTimeout(() => reject("Request timed out"), 10000); } - - if (websocket.readyState == 0) { - websocket.addEventListener("open", requestInner); - setTimeout(() => reject("Request timed out"), 10000); - } else { - requestInner(); - } - }) + } } /** - * This function will wait 3 seconds and them attempts to reconnect the WebSocket. - * If it fails, tries again in an endless loop until the connection is established - * again. - * + * Calculate exponential backoff delay + * @param attempt - Current reconnection attempt number + * @returns Delay in milliseconds * @private */ -async function onError() { - console.warn("WebSocket disconnected, retrying in 3 seconds..."); - await delay(3000); - websocket = create(); - - let listener: () => void | null; - listener = () => { - console.log("WebSocket successfully reconnected!"); - websocket.removeEventListener("open", listener); - } - - websocket.addEventListener("open", listener); - websocket.addEventListener("error", onError); +function getReconnectDelay(attempt: number): number { + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt); + return Math.min(delay, MAX_RECONNECT_DELAY); } -// -------------- -// Initialization -// -------------- +/** + * Handle WebSocket reconnection with exponential backoff + * @private + */ +async function reconnect(): Promise { + if (isReconnecting) { + return; + } + + isReconnecting = true; + + // Clean up old connection + cleanupWebSocket(); + + const delayMs = getReconnectDelay(reconnectAttempts); + reconnectAttempts++; + + await delay(delayMs); -websocket.addEventListener("message", (e) => { try { - const response: WebSocketMessage = JSON.parse(e.data); + websocket = create(); + setupEventHandlers(); + } catch (error) { + // If creation fails, try again + isReconnecting = false; + reconnect(); + } +} - // Handle call signaling messages - if (callSignalingHandler && response.type === "call_signaling" && response.data) { - callSignalingHandler.handleWebSocketMessage(response.data); +/** + * Setup event handlers for the WebSocket connection + * @private + */ +function setupEventHandlers(): void { + // Message handler + messageHandler = async (e: MessageEvent) => { + try { + const response: WebSocketMessage = JSON.parse(e.data); + + // Handle batched updates + if (response.type === "updates" && "seq" in response && "updates" in response) { + // Create function to request missed updates with credentials + const token = getAuthToken(); + const requestMissedFn = token ? async (lastSeq: number) => { + await requestMissedUpdates(lastSeq, async (req) => { + await request(req); + }, { + scheme: "Bearer", + credentials: token + }); + } : undefined; + + await processBatchedUpdates(response as any, (update) => { + // Route individual updates to appropriate handlers + handleUpdate(update); + }, requestMissedFn); + return; + } + + // Handle call signaling messages + if (callSignalingHandler && response.type === "call_signaling" && response.data) { + callSignalingHandler.handleWebSocketMessage(response.data); + } + + // Handle status and typing messages (these may come as immediate messages or in batches) + handleUpdate(response); + } catch (error) { + console.error("Error parsing WebSocket message:", error); } + }; - // Handle status and typing messages + // Helper function to handle individual updates + function handleUpdate(response: WebSocketMessage): void { if (response.type === "statusUpdate") { onlineStatusManager.handleStatusUpdate(response as any); } else if (response.type === "typing") { @@ -132,14 +205,14 @@ websocket.addEventListener("message", (e) => { typingManager.handleStopDmTyping(response as any); } else if (response.type === "suspended") { // Handle account suspension - const { setSuspended } = useAppState.getState(); + const { setSuspended } = useUserStore.getState(); const reason = response.data?.reason || "No reason provided"; setSuspended(reason); // Close WebSocket connection websocket.close(); } else if (response.type === "account_deleted") { // Handle account deletion - silent logout - const { logout } = useAppState.getState(); + const { logout } = useUserStore.getState(); logout(); // Close WebSocket connection websocket.close(); @@ -149,8 +222,196 @@ websocket.addEventListener("message", (e) => { if (globalMessageHandler) { globalMessageHandler(response); } - } catch (error) { - console.error("Error parsing WebSocket message:", error); } -}); -websocket.addEventListener("error", onError); \ No newline at end of file + websocket.addEventListener("message", messageHandler); + + // Open handler + openHandler = async () => { + reconnectAttempts = 0; // Reset on successful connection + isReconnecting = false; + + // Authenticate by sending ping with credentials and request missed updates + try { + const token = getAuthToken(); + if (token) { + const credentials = { + scheme: "Bearer", + credentials: token + }; + + // Send ping to authenticate and set user_by_ws on the server + try { + await request({ + type: "ping", + credentials, + data: {} + }); + } catch (error) { + console.error("Failed to send ping on reconnect:", error); + } + + // Send last sequence number and request missed updates on reconnect + // Wait a bit for ping to complete authentication + await delay(100); + + try { + const lastSeq = await getLastSequence(); + if (lastSeq > 0) { + await requestMissedUpdates(lastSeq, async (req) => { + await request(req); + }, credentials); + } + } catch (error) { + console.error("Failed to request missed updates:", error); + } + } + } catch (error) { + console.error("Failed to authenticate on reconnect:", error); + } + }; + websocket.addEventListener("open", openHandler); + + // Error handler + errorHandler = () => { + // Don't reconnect immediately on error - let close handler handle it + // This prevents double reconnection attempts + }; + websocket.addEventListener("error", errorHandler); + + // Close handler + closeHandler = (e: CloseEvent) => { + // Don't reconnect if it was a clean close (e.g., logout, suspension) + if (e.code === 1000 || e.code === 1001) { + return; + } + + // Reconnect for unexpected closes + if (!isReconnecting) { + reconnect(); + } + }; + websocket.addEventListener("close", closeHandler); +} + +export function request(payload: WebSocketMessage): Promise> { + console.log("WebSocket request:", payload); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error("Request timed out")); + }, 10000); + + function requestInner() { + if (websocket.readyState !== WebSocket.OPEN) { + clearTimeout(timeoutId); + reject(new Error("WebSocket is not open")); + return; + } + + const listener = (e: MessageEvent) => { + try { + const response = JSON.parse(e.data); + // Only handle responses that match our request type or have an error + if (response.type === payload.type || response.error) { + clearTimeout(timeoutId); + websocket.removeEventListener("message", listener); + + // Check if the response contains an error field + if (response.error) { + const error = new Error(response.error.detail || "WebSocket request failed"); + (error as HttpError).status = response.error.code; + (error as HttpError).detail = response.error.detail || ""; + reject(error); + } else { + resolve(response); + } + } + // If it doesn't match, let other handlers process it + } catch (error) { + clearTimeout(timeoutId); + websocket.removeEventListener("message", listener); + reject(error); + } + }; + + websocket.addEventListener("message", listener); + + try { + websocket.send(JSON.stringify(payload)); + } catch (error) { + clearTimeout(timeoutId); + websocket.removeEventListener("message", listener); + reject(error); + } + } + + if (websocket.readyState === WebSocket.CONNECTING) { + const openListener = () => { + websocket.removeEventListener("open", openListener); + requestInner(); + }; + websocket.addEventListener("open", openListener); + } else if (websocket.readyState === WebSocket.OPEN) { + requestInner(); + } else { + clearTimeout(timeoutId); + reject(new Error("WebSocket is closed")); + } + }); +} + +// -------------- +// Initialization +// -------------- + +/** + * Ensure WebSocket is connected and authenticated after login + * This should be called after successful authentication + */ +export async function ensureAuthenticated(): Promise { + const token = getAuthToken(); + if (!token) { + return; + } + + // If WebSocket is not connected, wait for it to connect + if (websocket.readyState === WebSocket.CONNECTING) { + await new Promise((resolve) => { + const checkConnection = () => { + if (websocket.readyState === WebSocket.OPEN) { + resolve(); + } else if (websocket.readyState === WebSocket.CLOSED) { + // Connection failed, try to reconnect + reconnect().then(() => { + setTimeout(checkConnection, 100); + }); + } else { + setTimeout(checkConnection, 100); + } + }; + checkConnection(); + }); + } else if (websocket.readyState === WebSocket.CLOSED) { + // Reconnect if closed + await reconnect(); + } + + // If WebSocket is open, send ping to authenticate + if (websocket.readyState === WebSocket.OPEN) { + try { + const credentials = { + scheme: "Bearer", + credentials: token + }; + + await request({ + type: "ping", + credentials, + data: {} + }); + } catch (error) { + console.error("Failed to send ping after login:", error); + } + } +} + +setupEventHandlers(); \ No newline at end of file diff --git a/frontend/src/css/_components.scss b/frontend/src/css/_components.scss index d7a4604..0f066b9 100644 --- a/frontend/src/css/_components.scss +++ b/frontend/src/css/_components.scss @@ -1,26 +1,6 @@ @use "material" as *; @use "sass:color"; -.text-center { - text-align: center; -} - -.alert { - padding: 0.8rem 1rem; - border-radius: 6px; - margin-bottom: 1rem; - - &.alert-success { - background-color: #C6F6D5; - color: #22543D; - } - - &.alert-danger { - background-color: #FED7D7; - color: #742A2A; - } -} - .link { color: $color-dark-primary; font-weight: 600; @@ -30,27 +10,6 @@ button, input { font: inherit; } -// Dialog content styles -.dialog-content { - h3 { - margin: 0 0 1rem 0; - color: $color-dark-on-surface; - font-size: 1.2rem; - font-weight: 600; - } - - mdui-text-field { - width: 100%; - } - - .dialog-actions { - display: flex; - gap: 0.75rem; - justify-content: flex-end; - margin-top: 1rem; - } -} - .rich-text-area { width: 100%; resize: none; @@ -83,33 +42,6 @@ button, input { } } -// Verified badge styles -.verified-badge { - display: inline-flex; - align-items: center; - color: $color-dark-primary; - vertical-align: middle; - user-select: none; - - &.small { - font-size: 14px; - width: 14px; - height: 14px; - } - - &.medium { - font-size: 18px; - width: 18px; - height: 18px; - } - - &.large { - font-size: 24px; - width: 24px; - height: 24px; - } -} - // Status badge styles (unified for verified and warning) .status-badge { display: inline-flex; @@ -121,7 +53,7 @@ button, input { } &.warning { - color: #ff9800; // Orange color for warnings + color: $color-dark-tertiary; // Purple-themed warning color } &.small mdui-icon { @@ -143,13 +75,6 @@ button, input { } } -// Profile dialog specific styles -.username-with-badge { - display: flex; - align-items: center; - gap: 8px; -} - .similarity-warning { display: flex; align-items: center; diff --git a/frontend/src/css/_material.scss b/frontend/src/css/_material.scss index ae8241c..b9c53c2 100644 --- a/frontend/src/css/_material.scss +++ b/frontend/src/css/_material.scss @@ -1,63 +1,59 @@ @use "sass:color"; // Dark -$color-dark-primary: rgb(145 206 244); -$color-dark-surface-tint: rgb(145 206 244); -$color-dark-on-primary: rgb(0 52 74); -$color-dark-primary-container: rgb(0 76 106); -$color-dark-on-primary-container: rgb(197 231 255); -$color-dark-secondary: rgb(182 201 216); -$color-dark-on-secondary: rgb(32 51 62); -$color-dark-secondary-container: rgb(55 73 85); -$color-dark-on-secondary-container: rgb(210 229 244); -$color-dark-tertiary: rgb(203 193 233); -$color-dark-on-tertiary: rgb(51 44 76); -$color-dark-tertiary-container: rgb(73 66 99); -$color-dark-on-tertiary-container: rgb(231 222 255); +// Generated from base color #9333EA (rgb(147, 51, 234)) +$color-dark-primary: rgb(219 185 249); +$color-dark-surface-tint: rgb(219 185 249); +$color-dark-on-primary: rgb(62 36 88); +$color-dark-primary-container: rgb(86 59 113); +$color-dark-on-primary-container: rgb(240 219 255); +$color-dark-secondary: rgb(208 193 218); +$color-dark-on-secondary: rgb(54 44 63); +$color-dark-secondary-container: rgb(77 67 86); +$color-dark-on-secondary-container: rgb(237 221 246); +$color-dark-tertiary: rgb(243 183 190); +$color-dark-on-tertiary: rgb(75 37 43); +$color-dark-tertiary-container: rgb(101 58 64); +$color-dark-on-tertiary-container: rgb(255 217 221); $color-dark-error: rgb(255 180 171); $color-dark-on-error: rgb(105 0 5); $color-dark-error-container: rgb(147 0 10); $color-dark-on-error-container: rgb(255 218 214); -$color-dark-background: rgb(15 20 23); -$color-dark-on-background: rgb(223 227 231); -$color-dark-surface: rgb(15 20 23); -$color-dark-on-surface: rgb(223 227 231); -$color-dark-surface-variant: rgb(65 72 77); -$color-dark-on-surface-variant: rgb(193 199 206); -$color-dark-outline: rgb(139 146 151); -$color-dark-outline-variant: rgb(65 72 77); +$color-dark-background: rgb(21 18 24); +$color-dark-on-background: rgb(232 224 232); +$color-dark-surface: rgb(21 18 24); +$color-dark-on-surface: rgb(232 224 232); +$color-dark-surface-variant: rgb(74 69 78); +$color-dark-on-surface-variant: rgb(204 196 206); +$color-dark-outline: rgb(150 142 152); +$color-dark-outline-variant: rgb(74 69 78); $color-dark-shadow: rgb(0 0 0); $color-dark-scrim: rgb(0 0 0); -$color-dark-inverse-surface: rgb(223 227 231); -$color-dark-inverse-on-surface: rgb(44 49 52); -$color-dark-inverse-primary: rgb(31 101 134); -$color-dark-primary-fixed: rgb(197 231 255); -$color-dark-on-primary-fixed: rgb(0 30 45); -$color-dark-primary-fixed-dim: rgb(145 206 244); -$color-dark-on-primary-fixed-variant: rgb(0 76 106); -$color-dark-secondary-fixed: rgb(210 229 244); -$color-dark-on-secondary-fixed: rgb(10 30 40); -$color-dark-secondary-fixed-dim: rgb(182 201 216); -$color-dark-on-secondary-fixed-variant: rgb(55 73 85); -$color-dark-tertiary-fixed: rgb(231 222 255); -$color-dark-on-tertiary-fixed: rgb(29 23 53); -$color-dark-tertiary-fixed-dim: rgb(203 193 233); -$color-dark-on-tertiary-fixed-variant: rgb(73 66 99); -$color-dark-surface-dim: rgb(15 20 23); -$color-dark-surface-bright: rgb(53 58 61); -$color-dark-surface-container-lowest: rgb(10 15 18); -$color-dark-surface-container-low: rgb(24 28 31); -$color-dark-surface-container: rgb(28 32 36); -$color-dark-surface-container-high: rgb(38 43 46); -$color-dark-surface-container-highest: rgb(49 53 57); +$color-dark-inverse-surface: rgb(232 224 232); +$color-dark-inverse-on-surface: rgb(51 47 53); +$color-dark-inverse-primary: rgb(111 82 138); +$color-dark-primary-fixed: rgb(240 219 255); +$color-dark-on-primary-fixed: rgb(40 13 66); +$color-dark-primary-fixed-dim: rgb(219 185 249); +$color-dark-on-primary-fixed-variant: rgb(86 59 113); +$color-dark-secondary-fixed: rgb(237 221 246); +$color-dark-on-secondary-fixed: rgb(33 24 41); +$color-dark-secondary-fixed-dim: rgb(208 193 218); +$color-dark-on-secondary-fixed-variant: rgb(77 67 86); +$color-dark-tertiary-fixed: rgb(255 217 221); +$color-dark-on-tertiary-fixed: rgb(50 16 22); +$color-dark-tertiary-fixed-dim: rgb(243 183 190); +$color-dark-on-tertiary-fixed-variant: rgb(101 58 64); +$color-dark-surface-dim: rgb(21 18 24); +$color-dark-surface-bright: rgb(60 56 62); +$color-dark-surface-container-lowest: rgb(16 13 18); +$color-dark-surface-container-low: rgb(30 26 32); +$color-dark-surface-container: rgb(34 30 36); +$color-dark-surface-container-high: rgb(44 41 46); +$color-dark-surface-container-highest: rgb(55 51 57); $color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%); $color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%); -// custom colors -$color-1: rgb(82, 109, 246); -$color-2: rgb(65, 11, 113); -$color-4: rgb(95, 26, 198); -$color-3: rgb(49, 71, 179); // Light $color-light-primary: rgb(31 101 134); $color-light-surface-tint: rgb(31 101 134); diff --git a/frontend/src/images/logo.png b/frontend/src/images/logo.png deleted file mode 100644 index 4eade18..0000000 Binary files a/frontend/src/images/logo.png and /dev/null differ diff --git a/frontend/src/images/logo.svg b/frontend/src/images/logo.svg new file mode 100644 index 0000000..c2cd2f0 --- /dev/null +++ b/frontend/src/images/logo.svg @@ -0,0 +1,2297 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + +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 +LIAXYesWS03LV1reUCErjoSRragIXYt3KUlA2KpPZAiKLIA3mjBl5LwIHTqcCibCy0DuSEQcxGr8 +CP8rDc27I1IOKg3NR9jxyOM+BvMQMr0jBrgg2VQILdKBqjWBKAvYxXRsew14EUYqEAyfCBUaLm8L +IqRQjAQGUCg0QSpBfCx+hKAHChcNlwmgHBSELBgQAhgEI4oIRyKZ7DanjPN9hBa30830sfgRfp8R +RRbAs5CYQAYinJh8qkghZsrIeZMGDBFMAFVxga1ItwyZhrT5CGGvSUCEE6+yuTJIKMKBjqFaFKyM +WIsiC+BFeMHEOP2A45lYp1wug0JgG4gwpiFtXqDjDC0HBUsb0Wq5RiSTEWEKtpLQPD7CVwSFxCnC +00QEhCnCUkVBJ0KIgcuLfCOK5cMYSGyy5KECGlIXG9Bg6IyM2817ZFYApeIQVYMINRMD/GpMaI0R +5eFFEZMliIFCw+096ES+ESVCiue7WNDvB1ALCZThwhEA0nhEiOuEQz0AJ0LvwtGcXuEiYTr9f6qG +V8bFM9kBR3P6CA8MKKaS14oomSS81yvCCya214AnUJs9rtcJFWxkttV20//sMs+c31576aQ/M80V +z5ynzLV2TmemmXYYfzuldT7usO1s6eN/p9LrrE1z2/p/s3yLH0/8d3aZ1n875eM76f98euul/RL3 +xbJ9dn8f3/v9+Hvi6k6nezf9/tPr0zO2/Z9bfrUdtXhm3O52/n9LTOvFct62d1ZL/3tKn7TLNFf6 +E//Xl9O/afeZ3p8X//vL6bOrtmmHsa2zTvxPq6Q+4Vflv/U56fz3K2me1+W8s8sVT5czT/h1Tv+r +lZ5n/ll/dhVj+t2YTtrlPGvTjv7faTF2SuGHq6yTdjVn67ji/9ky11rpy3qxrE4/U/hdKyum3bT3 +dr7YZ81UYky799x12vyfq2xaO/t932b/n9Xt5zvzv7US0wq/ztqKaWf8Xz1XWWvtHNuXnWln6dZm +KzvT7ieel+b7T1tm96ZyZkpl41vlzLSrM9OcbXfXKW++nbXZ3s/132JpM51X0sbw62amdd7q/znL +z7RznK/t/E+xxBN31y+2udp/emXtltlxV29/ptf+zywzrV3O13pf/8cu+zH8Osf1+/67lfVWny7r +1w7X3P21LTG1HaWzu7HM12fPp/l/Wmmv7SuznfKndzPXbmxlthV757jaSvv/K5bZZvj9lZner7fL +eeI5/f9vldnmDj99jPP/Yyzbbzer/7eV2Gb4dfY9u+P/d5fz3q5en149/3uVbnP3Pem9tbvny9u4 +c3r94u/ui+WtucNu6+PurlZaizunszZu/D+ptDPDr3PvWfPs7lplxdjKjLHtKr/irlq/Tf//tvyZ +u5/U0oq7m2ZZ/8Kvo5Zm+/bxldXebn7Gtd7/rznfx9PW7ntlfc/VZnvt7LZXVutw1s2MHV/6//Rl +nrm7bae1tLvtS/p2Uoih6RrmIN2knaKf4ilCmT3FU4SyrmeneIo2lmxFQ3lmKBvKwfNYGKgXiAUA +2IfSojonhQVwoarJvlmpDDWz1RgcKNsKlXuQoWA4GA2hfdSex8IIQChXg2DhcrLaLaVx1AkbC0dj +Uc+75WArFISNRbPVPs9jyVw0FE7FgvA8GCpC1XVXHaq6oUx1u2dHZ373vnZWjOX83DWUdbZrqOrw +z7b2q+dLqcw9oazr7k5qbVtrZ8VyWgpnXXc1Y/d393ypzI7hTdNliKHp5oWria5ROBQnABQEzqbp +LEXQ1FSqNXEr20mxpFE4S3CdVkJVrZKgWq2/uK4cFRpPxqAKhUFH+KAQAKGy1yTVwQiSSiYEQTPs +ZSUQcXEsXIZHXFy0i6sG3n0F0KChE2OMkOVCYQRQe906lq2Gu7Fsro7lQmGwcKiOBQDUzQVtLBkM +ZQuxXCiHGKFEYmMFAPVZ0TonR5jlVCrNZuOgwT6ji3NTd79X2pqxi8b6YLdzdt/HbeWsQbx9fGm9 +jxu7nDTYJzvNDPDTWTrzfExtri3xrLv33n58refOeme/3jPbK/F1eNN0NOLzzSzpzJZW92xztvDr +sJ2Vvu06rfc7VNlL7c3VZmnthR922dXt7SieF3em3bdlprjKay91txd+1HFtWjNt2z1d/myZ6624 +Yu/+lrRe/0und/9L3Jfef3td4mwf26d3SortrRPb9gu/jO23zH7f4ju9q/L+hd+UXu/E1rNLp7jD +j6fNuGanGH75uk/cvdPaN1/4de+1/qy0O0856aW368Q2/8Q312txbXozlZne+vPm96YXfjk7vZ2t +GFNbZze+0mbvXNZ6OzuuF37Yp71ur73w01/bp+2uWLa98KvSYlqv3+x9bbfN8qvDz63FTrPtpp1/ +s8/um+W93rmk2MIvu0/Z2L71SSW+9qtPbOGMVsdyZgtvmq5pC34+X791Odt2u08Lv25ppzu1L631 +v9nvldZa+M158bTU3porpW+t5+5+Kmet8KN9u9q1e+ydU+mZdvdSiv1mt945/rLnp/Pt9UvxT9p9 +rXRc4cyzvVTaXCEHnzAEtuDnA78Oz+mWXtqd8VPbf3H3xbizsmnF1r3a+7lW+HUZO/3PTT/PivF1 +607dHX4tK8YTy+qXYtzZ95qvY/u3c3odd7nxU0xxN9v2nX3vdNv9Wf7fLmfpGXf3Yluv03vvfL+0 +M/wwvV2dGXfX3H1f1s7YO2sr9Xm9m97vaymm9qnDz9utd7a5ducrZ2f4dVPWtjnji6vFuHOJvXZW +VsfVXoqfPu2uLWetnZW1cWd84Yd73pmtZ1lp7apP6dbCz9I6bbXS0lq7mqvMs9Yp8VOXtKuVGdPO +epbtuPbsxllSp31pZ23+3Hh21yyd0m5MZVvaVUzd53d3W/k/873vc3a//6XyK+3mxLVzpd3WJf4J +v1xpR98+tnd2U0wt7W7uXNvO7urSf1qowpY6lRNXKv2n7b46ptj/H7+0P+GXp/+80u/1n1+lbdzR +WtvmbCeFX3en1563u3tKr7j7r7Kddtnx9K7d7S2zxX7ppPjW2Z9ppnDW2Yuz7K6Q080JDahn21F8 +c849/+vXptknvdjCr/s6/z/bKym2ndsPhgSo03I2io6lJCKEjMgcadKkA2MQsOBC0+mUPhMCNUsd +E4CAR2JgWEQUCUt4KmPMiEgowYgEIpIkTbsByMCFXnfRpLAsEevQh/YNNkQrqgmkb1lrGB1lw+JT +hcAAC4UA7FPED31DSLMvxh0Zu3YdcBjWK+99Yz+OSr6rbxiGwQtvpD6crn6ACpLIdf6eyQS/oTEG +4co1ktvZ5vQAPsQ+u8tqvUZKFrhSYSAo9jmbMZp83QIgb3dAbeoCBOJjlZZp1anyYmCVmBPf/iZc +9+xPQpdApY93asms2SQgX3jxtyTFIxUZmwZlUejR8nEWpgI2Xh+KnA7DGWJKtzDCTcqBT6bCiPsJ ++n8Lcp9JiLOLMcEmnpEJfN3ljiY1N8GdKCXN//fACwFljn42fTfJQkvBpgujEpwPDbkMysDkW4qY +MbA7y8DistQjMVWLvcZpAET0oW/sCEbzk1T1YnimEqLrY2s/M3bEB+QYD6lUwhZO7OkNY+82RPhC +SXKk87hZb/8z4KgXnBTfE406wfROsJ54qP5urB8L/KIPg9KAnBzN+ilur5BSG/FI0u86p1zshD6e +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 + + + + \ No newline at end of file diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index dec0ff5..c4c51cc 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -1,21 +1,13 @@ -import { useEffect } from "react"; -import { useAppState } from "./chat/state"; -import { useNavigate } from "react-router-dom"; +import type { ReactNode } from "react"; +import { useUserStore } from "@/state/user"; +import { Navigate } from "react-router-dom"; interface ProtectedRouteProps { - children: React.ReactNode; + children: ReactNode; } export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { user } = useAppState(); - const navigate = useNavigate(); + const { user } = useUserStore(); - useEffect(() => { - if (!user.authToken) { - navigate("/login"); - return; - } - }, [user.authToken, user.currentUser, navigate]); - - return <>{children}; + return !user.authToken ? : children; } diff --git a/frontend/src/pages/auth/Auth.tsx b/frontend/src/pages/auth/Auth.tsx index 4a24ebd..b95a161 100644 --- a/frontend/src/pages/auth/Auth.tsx +++ b/frontend/src/pages/auth/Auth.tsx @@ -1,12 +1,30 @@ import type React from "react"; +import { motion, AnimatePresence } from "motion/react"; import styles from "./auth.module.scss"; export function AuthContainer({ children }: { children?: React.ReactNode }) { return (
-
+
+ {children} -
+
) } @@ -29,13 +47,63 @@ export function AuthHeader({ title, icon, subtitle }: AuthHeaderProps) { const iconName = typeof icon == "string" ? icon : icon.name; return ( -
+

- {iconName} + + {iconName} + {title}

-

{subtitle}

-
+ + {subtitle} + + ) } @@ -47,11 +115,40 @@ export interface Alert { } export function AlertsContainer({ alerts }: { alerts: Alert[]}) { + const displayAlerts = alerts.slice(-3); + return ( -
- {alerts.slice(-3).map((alert, i) => { - return
{alert.message}
- })} +
+ + {displayAlerts.map((alert, i) => ( + + {alert.message} + + ))} +
) } \ No newline at end of file diff --git a/frontend/src/pages/auth/AuthPage.tsx b/frontend/src/pages/auth/AuthPage.tsx new file mode 100644 index 0000000..5011317 --- /dev/null +++ b/frontend/src/pages/auth/AuthPage.tsx @@ -0,0 +1,179 @@ +import { AuthContainer } from "./Auth"; +import { useState, useEffect, useRef, useLayoutEffect, useCallback, type RefObject } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { motion, AnimatePresence } from "motion/react"; +import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; +import { LoginForm } from "./LoginForm"; +import { RegisterForm } from "./RegisterForm"; +import type { Variants, Transition } from "motion/react"; +import styles from "./auth.module.scss"; + +const slideVariants: Variants = { + enter: (direction: number) => ({ + x: direction > 0 ? 300 : -300, + opacity: 0 + }), + center: { + x: 0, + opacity: 1 + }, + exit: (direction: number) => ({ + x: direction > 0 ? -300 : 300, + opacity: 0 + }) +}; + +const slideTransition: Transition = { + x: { + type: "spring", + stiffness: 300, + damping: 30 + }, + opacity: { duration: 0.2 } +}; + + + +export default function AuthPage() { + const [searchParams] = useSearchParams(); + const { navigate: navigateDownloadApp } = useDownloadAppScreen(); + if (navigateDownloadApp) return navigateDownloadApp; + const navigate = useNavigate(); + + const [direction, setDirection] = useState(0); + const prevMode = useRef(searchParams.get("mode") || "login"); + const containerRef = useRef(null); + const loginFormRef = useRef(null); + const registerFormRef = useRef(null); + const [containerHeight, setContainerHeight] = useState("auto"); + const currentMode = searchParams.get("mode") || "login"; + const enteringElementRef = useRef<"login" | "register" | null>(null); + const [effectActivated, setEffectActivated] = useState(false); + + useEffect(() => { + if (prevMode.current !== currentMode) { + setDirection(currentMode === "register" ? 1 : -1); + prevMode.current = currentMode; + enteringElementRef.current = currentMode as "login" | "register"; + } + }, [currentMode]); + + const measureActiveHeight = useCallback(() => { + const activeComponent = currentMode === "login" ? loginFormRef.current : registerFormRef.current; + if (activeComponent) { + const height = activeComponent.scrollHeight; + if (height > 0) { + setContainerHeight(height); + } + } + }, [currentMode, loginFormRef, registerFormRef]); + + useLayoutEffect(() => { + if (!effectActivated) { + setEffectActivated(true); + return; + } + + // Always measure, but prioritize the entering element during transitions + // Use double requestAnimationFrame to ensure DOM is fully updated and layout is complete + let rafId2: number | null = null; + const rafId1 = requestAnimationFrame(() => { + rafId2 = requestAnimationFrame(() => { + measureActiveHeight(); + }); + }); + + return () => { + cancelAnimationFrame(rafId1); + if (rafId2 !== null) { + cancelAnimationFrame(rafId2); + } + }; + }, [currentMode]); + + function switchMode(newMode: "login" | "register") { + navigate(`/auth?mode=${newMode}`, { replace: true }); + } + + function handleAnimationComplete( + currentMode: "login" | "register", + mode: "login" | "register", + enteringElementRef: RefObject<"login" | "register" | null>, + formRef: React.RefObject, + setContainerHeight: (height: number) => void + ) { + return () => { + if (currentMode === mode && enteringElementRef.current === mode) { + enteringElementRef.current = null; + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (formRef.current && currentMode === mode) { + const height = formRef.current.scrollHeight; + if (height > 0) { + setContainerHeight(height); + } + } + }); + }); + } + } + } + + return ( + +
{ + + }} + onAnimationEnd={() => { + setContainerHeight("auto"); + }} + > + + {currentMode === "login" ? ( + + switchMode("register")} /> + + ) : ( + + switchMode("login")} /> + + )} + +
+
+ ) +} diff --git a/frontend/src/pages/auth/AuthTextField.tsx b/frontend/src/pages/auth/AuthTextField.tsx new file mode 100644 index 0000000..6b6f8fe --- /dev/null +++ b/frontend/src/pages/auth/AuthTextField.tsx @@ -0,0 +1,138 @@ +import { forwardRef, useImperativeHandle, useRef, useState, useEffect } from "react"; +import { motion } from "motion/react"; +import styles from "./auth.module.scss"; + +export interface AuthTextFieldHandle { + value: string; + focus: () => void; + blur: () => void; +} + +export interface AuthTextFieldProps { + label: string; + name?: string; + type?: string; + icon?: string; + autocomplete?: string; + required?: boolean; + maxlength?: number; + counter?: boolean; + "toggle-password"?: boolean; + defaultValue?: string; + value?: string; + onChange?: (value: string) => void; + className?: string; +} + +export const AuthTextField = forwardRef( + ({ + label, + name, + type = "text", + icon, + autocomplete, + required = false, + maxlength, + counter = false, + "toggle-password": togglePassword = false, + defaultValue = "", + value: controlledValue, + onChange, + className = "" + }, ref) => { + const [internalValue, setInternalValue] = useState(defaultValue); + const [isFocused, setIsFocused] = useState(false); + const [showPassword, setShowPassword] = useState(false); + const [charCount, setCharCount] = useState(0); + const inputRef = useRef(null); + + const isControlled = controlledValue !== undefined; + const value = isControlled ? controlledValue : internalValue; + const displayType = togglePassword && type === "password" ? (showPassword ? "text" : "password") : type; + + useEffect(() => { + if (!isControlled) { + setInternalValue(defaultValue); + } + }, [defaultValue, isControlled]); + + useEffect(() => { + setCharCount(value.length); + }, [value]); + + useImperativeHandle(ref, () => ({ + get value() { + return value; + }, + focus: () => { + inputRef.current?.focus(); + }, + blur: () => { + inputRef.current?.blur(); + } + })); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.value; + if (!isControlled) { + setInternalValue(newValue); + } + onChange?.(newValue); + }; + + const hasError = false; // Can be extended for validation + + return ( + +
+ {icon && ( + + {icon.replace("--filled", "").replace("--outlined", "")} + + )} +
+ setIsFocused(true)} + onBlur={() => setIsFocused(false)} + autoComplete={autocomplete} + required={required} + maxLength={maxlength} + placeholder={label + (required ? " *" : "")} + className={styles.input} + /> +
+ {togglePassword && type === "password" && ( + + )} +
+ {counter && maxlength && ( +
+ {charCount} / {maxlength} +
+ )} +
+ ); + } +); + +AuthTextField.displayName = "AuthTextField"; diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx new file mode 100644 index 0000000..17f96c1 --- /dev/null +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -0,0 +1,215 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { motion, type Transition, type Variants } from "motion/react"; +import { useImmer } from "use-immer"; +import type { LoginRequest } from "@/core/types"; +import { useUserStore } from "@/state/user"; +import { MaterialButton } from "@/utils/material"; +import api from "@/core/api"; +import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; +import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; +import { isElectron } from "@/core/electron/electron"; +import type { Alert, AlertType } from "./Auth"; +import { AuthHeader, AlertsContainer } from "./Auth"; +import styles from "./auth.module.scss"; +import { ensureAuthenticated } from "@/core/websocket"; + +const loginFieldVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const loginFieldTransition: Transition = { + duration: 0.3, + ease: "easeInOut" +}; + +const loginButtonVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const loginButtonTransition: Transition = { + duration: 0.3, + delay: 0.4, + ease: "easeInOut" +}; + +interface LoginFormProps { + onSwitchMode: () => void; +} + +export function LoginForm({ onSwitchMode }: LoginFormProps) { + const [isLoading, setIsLoading] = useState(false); + const [alerts, updateAlerts] = useImmer([]); + const setUser = useUserStore(state => state.setUser); + const navigate = useNavigate(); + + function showAlert(type: AlertType, message: string) { + updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); + } + + const usernameElement = useRef(null); + const passwordElement = useRef(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (isLoading) return; + + const username = usernameElement.current!.value.trim(); + const password = passwordElement.current!.value.trim(); + + if (!username || !password) { + showAlert("danger", "Пожалуйста, заполните все поля"); + return; + } + + setIsLoading(true); + + try { + const derived = await api.user.auth.deriveAuthSecret(username, password); + const request: LoginRequest = { + username: username, + password: derived + } + + try { + const data = await api.user.auth.login(request); + setUser(data.token, data.user); + + try { + await api.user.auth.ensureKeysOnLogin(password, data.token); + } catch (e) { + console.error("Key setup failed:", e); + } + + // Ensure WebSocket is connected and authenticated + try { + await ensureAuthenticated(); + } catch (e) { + console.error("WebSocket authentication failed:", e); + } + + navigate("/chat"); + + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(data.token); + + if (isElectron) { + await startElectronReceiver(); + } + + console.log("Notifications enabled"); + } else { + console.log("Notification permission denied"); + } + } else { + console.log("Notifications not supported"); + } + } catch (e) { + console.error("Notification setup failed:", e); + } + } catch (error: any) { + if (error.message && error.message.includes("suspension")) { + const setSuspended = useUserStore.getState().setSuspended; + setSuspended(error.message || "No reason provided"); + return; + } + showAlert("danger", error.message || "Неверное имя пользователя или пароль"); + } + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); + } finally { + setIsLoading(false); + } + } + + return ( + <> + +
+ + + + + + + + + + +
+ + + {isLoading ? "Вход..." : "Войти"} + + +
+
+ +

+ Ещё нет аккаунта? + { + e.preventDefault(); + onSwitchMode(); + }}> + Зарегистрируйтесь + +

+
+ + ); +} + diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx deleted file mode 100644 index 15e8c60..0000000 --- a/frontend/src/pages/auth/LoginPage.tsx +++ /dev/null @@ -1,155 +0,0 @@ -import { useImmer } from "use-immer"; -import { AlertsContainer, type Alert, type AlertType } from "./Auth"; -import { AuthContainer, AuthHeader } from "./Auth"; -import type { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; -import { API_BASE_URL } from "@/core/config"; -import { useRef } from "react"; -import type { TextField } from "mdui/components/text-field"; -import { useAppState } from "@/pages/chat/state"; -import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; -import { isElectron } from "@/core/electron/electron"; -import { useNavigate } from "react-router-dom"; -import styles from "./auth.module.scss"; -import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; -import { MaterialButton, MaterialTextField } from "@/utils/material"; - -export default function LoginPage() { - const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); - const navigate = useNavigate(); - const { navigate: navigateDownloadApp } = useDownloadAppScreen(); - if (navigateDownloadApp) return navigateDownloadApp; - - function showAlert(type: AlertType, message: string) { - updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); - } - - const usernameElement = useRef(null); - const passwordElement = useRef(null); - - return ( - - -
- - -
{ - e.preventDefault(); - - const username = usernameElement.current!.value.trim(); - const password = passwordElement.current!.value.trim(); - - if (!username || !password) { - showAlert("danger", "Пожалуйста, заполните все поля"); - return; - } - - try { - const derived = await deriveAuthSecret(username, password); - const request: LoginRequest = { - username: username, - password: derived - } - - const response = await fetch(`${API_BASE_URL}/login`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request) - }); - - if (response.ok) { - const data: LoginResponse = await response.json(); - // Store the JWT token first - setUser(data.token, data.user); - - // Setup keys with the token we just received - try { - await ensureKeysOnLogin(password, data.token); - } catch (e) { - console.error("Key setup failed:", e); - } - - navigate("/chat"); - - // Initialize notifications - try { - if (isSupported()) { - const initialized = await initialize(); - if (initialized) { - await subscribe(data.token); - - // For Electron, start the notification receiver - if (isElectron) { - await startElectronReceiver(); - } - - console.log("Notifications enabled"); - } else { - console.log("Notification permission denied"); - } - } else { - console.log("Notifications not supported"); - } - } catch (e) { - console.error("Notification setup failed:", e); - } - } else { - const data: ErrorResponse = await response.json(); - - // Check for suspension - if (response.status === 403 && response.headers.get("suspension_reason")) { - const suspensionReason = response.headers.get("suspension_reason"); - const setSuspended = useAppState.getState().setSuspended; - setSuspended(suspensionReason || "No reason provided"); - return; // Don't show alert, SuspensionDialog will be shown - } - - showAlert("danger", data.message || "Неверное имя пользователя или пароль"); - } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); - } - }}> - - - - - - Войти - - - -
-
- ) -} diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx new file mode 100644 index 0000000..81fdfa4 --- /dev/null +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -0,0 +1,239 @@ +import { useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { motion, type Transition, type Variants } from "motion/react"; +import { useImmer } from "use-immer"; +import type { RegisterRequest } from "@/core/types"; +import { useUserStore } from "@/state/user"; +import { MaterialButton, MaterialIconButton } from "@/utils/material"; +import api from "@/core/api"; +import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; +import type { Alert, AlertType } from "./Auth"; +import { AuthHeader, AlertsContainer } from "./Auth"; +import styles from "./auth.module.scss"; + +const registerFieldVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const registerFieldTransition: Transition = { + duration: 0.3, + ease: "easeInOut" +}; + +const registerButtonVariants: Variants = { + initial: { + opacity: 0, + y: 10 + }, + animate: { + opacity: 1, + y: 0 + } +}; + +const registerButtonTransition: Transition = { + duration: 0.3, + delay: 0.6, + ease: "easeInOut" +}; + +interface RegisterFormProps { + onSwitchMode: () => void; +} + +export function RegisterForm({ onSwitchMode }: RegisterFormProps) { + const [isLoading, setIsLoading] = useState(false); + const [alerts, updateAlerts] = useImmer([]); + const setUser = useUserStore(state => state.setUser); + const navigate = useNavigate(); + + function showAlert(type: AlertType, message: string) { + updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); + } + + const displayNameElement = useRef(null); + const usernameElement = useRef(null); + const passwordElement = useRef(null); + const confirmPasswordElement = useRef(null); + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + + if (isLoading) return; + + const displayName = displayNameElement.current!.value.trim(); + const username = usernameElement.current!.value.trim(); + const password = passwordElement.current!.value.trim(); + const confirmPassword = confirmPasswordElement.current!.value.trim(); + + if (!displayName || !username || !password || !confirmPassword) { + showAlert("danger", "Пожалуйста, заполните все поля"); + return; + } + + if (password !== confirmPassword) { + showAlert("danger", "Пароли не совпадают"); + return; + } + + if (displayName.length < 1 || displayName.length > 64) { + showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов"); + return; + } + + if (username.length < 3 || username.length > 20) { + showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов"); + return; + } + + if (!/^[a-zA-Z0-9_-]+$/.test(username)) { + showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания"); + return; + } + + if (password.length < 5 || password.length > 50) { + showAlert("danger", "Пароль должен быть от 5 до 50 символов"); + return; + } + + setIsLoading(true); + + try { + const derived = await api.user.auth.deriveAuthSecret(username, password); + const request: RegisterRequest = { + display_name: displayName, + username: username, + password: derived, + confirm_password: derived + } + + try { + const data = await api.user.auth.register(request); + setUser(data.token, data.user); + + try { + await api.user.auth.ensureKeysOnLogin(password, data.token); + } catch (e) { + console.error("Key setup failed:", e); + } + + navigate("/chat"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка при регистрации"); + } + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); + } finally { + setIsLoading(false); + } + } + + return ( + <> + +
+ + + + + + + + + + + + + + + +
+ + + + + + {isLoading ? "Регистрация..." : "Зарегистрироваться"} + + +
+ +
+
+ + ); +} + diff --git a/frontend/src/pages/auth/RegisterPage.tsx b/frontend/src/pages/auth/RegisterPage.tsx deleted file mode 100644 index 991ec68..0000000 --- a/frontend/src/pages/auth/RegisterPage.tsx +++ /dev/null @@ -1,173 +0,0 @@ -import { useImmer } from "use-immer"; -import { AuthContainer, AuthHeader } from "./Auth"; -import { AlertsContainer, type Alert, type AlertType } from "./Auth"; -import { useRef } from "react"; -import { TextField } from "mdui/components/text-field"; -import type { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; -import { API_BASE_URL } from "@/core/config"; -import { useAppState } from "@/pages/chat/state"; -import { MaterialButton, MaterialTextField } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; -import { useNavigate } from "react-router-dom"; -import styles from "./auth.module.scss"; -import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; - -export default function RegisterPage() { - const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); - const navigate = useNavigate(); - const { navigate: navigateDownloadApp } = useDownloadAppScreen(); - if (navigateDownloadApp) return navigateDownloadApp; - - function showAlert(type: AlertType, message: string) { - updateAlerts((alerts) => { alerts.push({type: type, message: message}) }); - } - - const displayNameElement = useRef(null); - const usernameElement = useRef(null); - const passwordElement = useRef(null); - const confirmPasswordElement = useRef(null); - - return ( - - -
- - -
{ - e.preventDefault(); - - const displayName = displayNameElement.current!.value.trim(); - const username = usernameElement.current!.value.trim(); - const password = passwordElement.current!.value.trim(); - const confirmPassword = confirmPasswordElement.current!.value.trim(); - - if (!displayName || !username || !password || !confirmPassword) { - showAlert("danger", "Пожалуйста, заполните все поля"); - return; - } - - if (password !== confirmPassword) { - showAlert("danger", "Пароли не совпадают"); - return; - } - - if (displayName.length < 1 || displayName.length > 64) { - showAlert("danger", "Отображаемое имя должно быть от 1 до 64 символов"); - return; - } - - if (username.length < 3 || username.length > 20) { - showAlert("danger", "Имя пользователя должно быть от 3 до 20 символов"); - return; - } - - // Validate username format (only English letters, numbers, dashes, underscores) - if (!/^[a-zA-Z0-9_-]+$/.test(username)) { - showAlert("danger", "Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания"); - return; - } - - if (password.length < 5 || password.length > 50) { - showAlert("danger", "Пароль должен быть от 5 до 50 символов"); - return; - } - - try { - const derived = await deriveAuthSecret(username, password); - const request: RegisterRequest = { - display_name: displayName, - username: username, - password: derived, - confirm_password: derived - } - - const response = await fetch(`${API_BASE_URL}/register`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(request) - }); - - if (response.ok) { - const data: LoginResponse = await response.json(); - // Store the JWT token first - setUser(data.token, data.user); - - // Setup keys with the token we just received - try { - await ensureKeysOnLogin(password, data.token); - } catch (e) { - console.error("Key setup failed:", e); - } - - navigate("/chat"); - } else { - const data: ErrorResponse = await response.json(); - showAlert("danger", data.message || "Ошибка при регистрации"); - } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); - } - }}> - - - - - - Зарегистрироваться - - -
-

- Уже есть аккаунт? - navigate("/login")}> - Войдите - -

-
-
-
- ) -} diff --git a/frontend/src/pages/auth/auth.module.scss b/frontend/src/pages/auth/auth.module.scss index ca1560f..846bdbb 100644 --- a/frontend/src/pages/auth/auth.module.scss +++ b/frontend/src/pages/auth/auth.module.scss @@ -1,52 +1,321 @@ +@use "sass:color"; @use "../../css/colors" as *; @use "../../css/material" as *; +@keyframes rotateGradient { + from { + transform: translate(-50%, -50%) rotate(0deg); + } + to { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +@keyframes slideInDown { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shake { + 0%, 100% { + transform: translateX(0); + } + 10%, 30%, 50%, 70%, 90% { + transform: translateX(-4px); + } + 20%, 40%, 60%, 80% { + transform: translateX(4px); + } +} + .authContainer { display: flex; justify-content: center; align-items: center; - height: 100%; + min-height: 100vh; + width: 100vw; padding: 2rem; - background-color: $color-dark-surface; + position: fixed; + top: 0; + left: 0; + overflow: hidden; + background: $color-dark-surface; + .gradientBackground { + $size: 550px; + + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: $size; + height: $size; + background: conic-gradient( + from 0deg, + rgba(147, 51, 234, 0.5) 0%, + rgba(99, 102, 241, 0.6) 12.5%, + rgba(59, 130, 246, 0.55) 25%, + rgba(168, 85, 247, 0.5) 37.5%, + rgba(217, 70, 239, 0.6) 50%, + rgba(236, 72, 153, 0.55) 62.5%, + rgba(192, 132, 252, 0.5) 75%, + rgba(126, 34, 206, 0.6) 87.5%, + rgba(147, 51, 234, 0.5) 100% + ); + animation: rotateGradient 8s linear infinite; + border-radius: 50%; + filter: blur(80px); + z-index: 0; + will-change: transform; + backface-visibility: hidden; + } + .authCard { - background-color: $color-dark-surface-container; + background: rgba($color-dark-surface-container, 0.7); + backdrop-filter: blur(20px); color: $color-dark-on-surface; - border-radius: 12px; - box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1); + border-radius: 24px; + border: 1px solid rgba($color-dark-outline, 0.1); + box-shadow: + 0 20px 60px rgba(0, 0, 0, 0.3), + 0 0 0 1px rgba($color-dark-primary, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.05); width: 100%; max-width: 450px; overflow: hidden; - animation: authCardAnimation 0.3s ease-in-out; - } + position: relative; + z-index: 1; - .authHeader { - margin: 0; - padding: 16px; - padding-bottom: 0; - text-align: center; + .formWrapper { + position: absolute; + width: 100%; + top: 0; + left: 0; - h2 { - font-size: 1.8rem; - margin: 0; - margin-bottom: 0.5rem; - align-items: center; - display: flex; - flex-direction: row; - gap: 10px; - justify-content: center; - } - } - - .authBody { - padding: 25px; - padding-bottom: 16px; - - form { - display: flex; - flex-direction: column; - gap: 10px; + .authHeader { + margin: 0; + padding: 24px; + padding-bottom: 8px; + text-align: center; + + h2 { + font-size: 1.8rem; + margin: 0; + margin-bottom: 0.5rem; + align-items: center; + display: flex; + flex-direction: row; + gap: 10px; + justify-content: center; + font-weight: 600; + + .material-symbols { + color: $color-dark-primary; + filter: drop-shadow(0 0 8px rgba($color-dark-primary, 0.4)); + } + } + + p { + color: $color-dark-on-surface-variant; + font-size: 0.95rem; + margin: 0; + } + } + + .authBody { + padding: 24px; + padding-bottom: 20px; + + form { + display: flex; + flex-direction: column; + gap: 16px; + + .authButtons { + display: flex; + flex-direction: row; + gap: 16px; + } + } + + .registerLink { + text-align: center; + margin-top: 16px; + font-size: 0.9rem; + color: $color-dark-on-surface-variant; + + a { + color: $color-dark-primary; + margin-inline-start: 3px; + } + } + } } } } +// AuthTextField Styles +.authTextField { + position: relative; + width: 100%; + + .fieldContainer { + position: relative; + display: flex; + align-items: center; + gap: 10px; + background: rgba($color-dark-surface-variant, 0.3); + border: 1px solid rgba($color-dark-outline, 0.2); + border-radius: 12px; + padding: 0 0 0 12px; + transition: all 0.3s ease; + min-height: 44px; + + &:hover { + border-color: rgba($color-dark-outline, 0.4); + background: rgba($color-dark-surface-variant, 0.4); + } + + &.focused { + border-color: $color-dark-primary; + background: rgba($color-dark-surface-variant, 0.5); + box-shadow: + 0 0 0 4px rgba($color-dark-primary, 0.1), + 0 4px 12px rgba($color-dark-primary, 0.2); + } + + &.error { + border-color: $color-dark-error; + animation: shake 0.4s ease; + + &.focused { + box-shadow: + 0 0 0 4px rgba($color-dark-error, 0.1), + 0 4px 12px rgba($color-dark-error, 0.2); + } + } + + &.noIcon { + gap: 0; + + .inputWrapper { + margin-left: 0; + } + } + + &.hasToggle { + padding-right: 12px; + } + } + + .fieldIcon { + color: $color-dark-on-surface-variant; + font-size: 18px; + flex-shrink: 0; + transition: color 0.3s ease; + + .fieldContainer.focused & { + color: $color-dark-primary; + } + } + + .inputWrapper { + position: relative; + flex: 1; + display: flex; + align-items: center; + min-height: 44px; + } + + .input { + width: 100%; + background: transparent; + border: none; + outline: none; + color: $color-dark-on-surface; + font-size: 0.95rem; + font-family: inherit; + padding: 12px 0 12px 0; + line-height: 1.4; + height: auto; + min-height: 20px; + + &::placeholder { + color: $color-dark-on-surface-variant; + opacity: 0.7; + } + + &:focus::placeholder { + opacity: 0.5; + } + } + + .togglePassword { + background: none; + border: none; + color: $color-dark-on-surface-variant; + cursor: pointer; + padding: 6px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 6px; + transition: all 0.2s ease; + flex-shrink: 0; + + &:hover { + background: rgba($color-dark-on-surface, 0.1); + color: $color-dark-on-surface; + } + + &:active { + transform: scale(0.95); + } + + .material-symbols { + font-size: 18px; + } + } + + .counter { + margin-top: 4px; + padding-left: 16px; + font-size: 0.75rem; + color: $color-dark-on-surface-variant; + text-align: right; + } +} + +// Alert Styles +.alertContainer { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 16px; + + .alert { + padding: 12px 16px; + border-radius: 12px; + font-size: 0.9rem; + line-height: 1.5; + animation: slideInDown 0.3s ease; + + &.alert-success { + background: rgba($color-dark-primary-container, 0.3); + color: $color-dark-on-primary-container; + border: 1px solid rgba($color-dark-primary, 0.3); + } + + &.alert-danger { + background: rgba($color-dark-error-container, 0.3); + color: $color-dark-on-error-container; + border: 1px solid rgba($color-dark-error, 0.3); + } + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/css/ChatInput.module.scss b/frontend/src/pages/chat/css/ChatInput.module.scss index 40a5108..5376b99 100644 --- a/frontend/src/pages/chat/css/ChatInput.module.scss +++ b/frontend/src/pages/chat/css/ChatInput.module.scss @@ -2,24 +2,6 @@ @use "../../../css/material" as *; @use "sass:color"; -// Reply preview styles (shared with Message component) -.quote.contextualContent > .quoteInner { - display: flex; - flex-direction: column; - gap: 4px; - - .replyUsername { - font-weight: 600; - color: $color-dark-on-surface; - font-size: 0.85rem; - } - - .replyText { - overflow: hidden; - text-overflow: ellipsis; - } -} - .chatInputWrapper { position: relative; margin: 0 10px 10px 10px; @@ -116,7 +98,7 @@ width: 50px; height: 50px; border-radius: 50%; - background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%)); + background-color: $color-dark-primary; color: $color-dark-on-primary; border: 1px solid rgba($color-dark-primary, 0.5); cursor: pointer; diff --git a/frontend/src/pages/chat/css/Message.module.scss b/frontend/src/pages/chat/css/Message.module.scss index 6b61916..c49fafd 100644 --- a/frontend/src/pages/chat/css/Message.module.scss +++ b/frontend/src/pages/chat/css/Message.module.scss @@ -2,27 +2,11 @@ @use "../../../css/material" as *; @use "sass:color"; -.quote.contextualContent > .quoteInner { - display: flex; - flex-direction: column; - gap: 4px; - - .replyUsername { - font-weight: 600; - color: $color-dark-on-surface; - font-size: 0.85rem; - } - - .replyText { - overflow: hidden; - text-overflow: ellipsis; - } -} .message { $status-indicator-size: 16px; - margin-bottom: 1rem; + margin-bottom: 10px; max-width: 70%; position: relative; width: fit-content; @@ -30,30 +14,8 @@ align-items: flex-start; gap: 8px; - &.received { - .messageProfilePic { - width: 40px; - height: 40px; - flex-shrink: 0; - cursor: pointer; - transition: transform 0.2s ease; - - &:hover { - transform: scale(1.05); - } - - img { - width: 100%; - height: 100%; - border-radius: 50%; - object-fit: cover; - border: 2px solid $color-dark-outline; - } - } - } - .messageInner { - border-radius: 12px; + border-radius: 20px 20px 8px 8px; // Top corners rounded, bottom corners sharper position: relative; word-wrap: break-word; overflow-wrap: anywhere; @@ -72,6 +34,7 @@ display: flex; align-items: center; gap: 4px; + width: fit-content; &:hover { transform: scale(1.05); @@ -92,9 +55,10 @@ } } - .quote.replyPreview { + :global(.quote).replyPreview { user-select: none; - margin: 10px; + margin: 5px; + border-radius: 16px; } .messageAttachments { @@ -194,10 +158,31 @@ } &.received { + .messageProfilePic { + width: 40px; + height: 40px; + flex-shrink: 0; + cursor: pointer; + transition: transform 0.2s ease; + align-self: flex-end; + + &:hover { + transform: scale(1.05); + } + + img { + width: 100%; + height: 100%; + border-radius: 50%; + object-fit: cover; + border: 2px solid $color-dark-outline; + } + } + .messageInner { background: $color-dark-surface-container; color: $color-dark-on-surface; - border-top-left-radius: 5px; + border-radius: 20px 20px 20px 8px; // Top-left: 5px, top-right: 20px, bottom: 8px box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); border: 1px solid rgba($color-dark-outline-variant, 0.4); position: relative; @@ -210,7 +195,7 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, rgba($color-dark-primary, 0.05), rgba($color-dark-tertiary, 0.03)); + background: linear-gradient(135deg, rgba(147, 51, 234, 0.05), rgba(99, 102, 241, 0.03)); pointer-events: none; z-index: 0; } @@ -231,27 +216,27 @@ margin-left: auto; flex-direction: row-reverse; + :global(.quote).replyPreview { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08)); + border: 1px solid rgba(255, 255, 255, 0.2); + position: relative; + overflow: hidden; + box-shadow: 0 2px 8px rgba(147, 51, 234, 0.5); + + :global(.quote-inner) { + position: relative; + z-index: 1; + } + } + .messageInner { - background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%)); - color: $color-dark-on-primary; - border-top-right-radius: 5px; + background: linear-gradient(135deg, #9333EA, #6366F1, #2f68c5); + border-radius: 20px 20px 8px 20px; // Top-left: 20px, top-right: 5px, bottom: 8px box-shadow: 0 0 20px rgba($color-dark-primary, 0.4); border: 1px solid rgba($color-dark-primary, 0.5); position: relative; overflow: hidden; - &::before { - content: ''; - position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; - background: linear-gradient(135deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0.08)); - pointer-events: none; - z-index: 0; - } - > * { position: relative; z-index: 1; @@ -273,7 +258,6 @@ } .messageTime { - color: $color-dark-on-primary; font-weight: 500; } } diff --git a/frontend/src/pages/chat/css/callWindow.module.scss b/frontend/src/pages/chat/css/callWindow.module.scss index 473058f..85602a1 100644 --- a/frontend/src/pages/chat/css/callWindow.module.scss +++ b/frontend/src/pages/chat/css/callWindow.module.scss @@ -29,7 +29,6 @@ height: 100vh; background-color: rgba($color-dark-surface, 0.98); backdrop-filter: blur(40px); - -webkit-backdrop-filter: blur(40px); border: none; border-radius: 0; cursor: default; @@ -64,7 +63,6 @@ height: 300px; background-color: rgba($color-dark-surface, 0.95); backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); border: 2px solid rgba($color-dark-outline, 0.4); border-radius: 16px; box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5); @@ -415,7 +413,6 @@ font-weight: 600; border-radius: 8px; backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); } &.localVideo { @@ -455,7 +452,6 @@ color: $color-dark-on-primary; border-radius: 8px; backdrop-filter: blur(10px); - -webkit-backdrop-filter: blur(10px); pointer-events: none; z-index: 1; } diff --git a/frontend/src/pages/chat/css/layout.module.scss b/frontend/src/pages/chat/css/layout.module.scss index b07ecfb..165e9b3 100644 --- a/frontend/src/pages/chat/css/layout.module.scss +++ b/frontend/src/pages/chat/css/layout.module.scss @@ -4,13 +4,14 @@ .chatInterface { height: 100%; - background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%); + background: $color-dark-background; + // background: linear-gradient(135deg, $color-dark-background 0%, $color-dark-surface-container 70%, rgba($color-dark-primary-container, 0.3) 100%); position: relative; overflow: hidden; &::before { content: ''; - position: fixed; + position: absolute; top: 0; left: 0; right: 0; @@ -20,7 +21,7 @@ radial-gradient(circle at 80% 20%, rgba($color-dark-tertiary, 0.15) 0%, transparent 50%), radial-gradient(circle at 40% 40%, rgba($color-dark-secondary, 0.1) 0%, transparent 50%); pointer-events: none; - z-index: 0; + z-index: 10; } .allContainer { diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index d0e998d..932a64f 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -25,16 +25,25 @@ justify-content: center; align-items: center; padding: 16px; + user-select: none; + + .logo { + $size: 35px; + + width: $size; + height: $size; + margin-right: 8px; + } .productName { flex-grow: 1; font-size: 1.8rem; font-weight: 700; - background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background: linear-gradient(45deg, #9333EA, #6366F1, #3B82F6, #A855F7, #D946EF, #EC4899, #C084FC, #7E22CE); + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; - text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); + text-shadow: 0 0 20px rgba(147, 51, 234, 0.5); } .profile { @@ -66,45 +75,11 @@ } } - .chatTabs { - margin-top: 5px; - width: 100%; - height: calc(100% - 80px); - display: flex; - flex-direction: column; - min-height: 0; // prevent flex collapse when inner overflows - --mdui-color-surface: $color-dark-surface-container; - --mdui-color-surface-variant: transparent; - - img { - width: 45px; - height: 45px; - border-radius: 20%; - object-fit: cover; - } - - mdui-tabs { - height: 100%; - display: flex; - flex-direction: column; - min-height: 0; // enable inner panel to scroll - } - - mdui-tab-panel[active] { - flex: 1; - display: flex; - flex-direction: column; - min-height: 0; // critical to avoid collapsing - overflow: auto; - } - - mdui-list { - flex: 1; - min-height: 0; // allow scroll area to size correctly - overflow-y: auto; - padding: 0; - margin: 0; - } + .unifiedChatsList { + flex: 1; + min-height: 0; // allow scroll area to size correctly + overflow-y: auto; + margin-top: 10px; } // Search container @@ -134,6 +109,7 @@ padding: 32px; color: $color-dark-on-surface-variant; text-align: center; + overflow: hidden; } // Custom styling for search result images diff --git a/frontend/src/pages/chat/css/profile-dialog.module.scss b/frontend/src/pages/chat/css/profile-dialog.module.scss index e10b4c9..3a1d9a8 100644 --- a/frontend/src/pages/chat/css/profile-dialog.module.scss +++ b/frontend/src/pages/chat/css/profile-dialog.module.scss @@ -53,6 +53,9 @@ .usernameWithBadge { gap: 0; + display: flex; + flex-direction: row; + align-items: center; .usernameInput { background: none; diff --git a/frontend/src/pages/chat/css/reply-preview.module.scss b/frontend/src/pages/chat/css/reply-preview.module.scss new file mode 100644 index 0000000..e3164cb --- /dev/null +++ b/frontend/src/pages/chat/css/reply-preview.module.scss @@ -0,0 +1,19 @@ +@use "../../../css/colors" as *; +@use "../../../css/material" as *; + +:global(.quote).contextualContent > :global(.quote-inner) { + display: flex; + flex-direction: column; + gap: 4px; + + .replyUsername { + font-weight: 600; + color: $color-dark-on-surface; + font-size: 0.85rem; + } + + .replyText { + overflow: hidden; + text-overflow: ellipsis; + } +} \ No newline at end of file diff --git a/frontend/src/pages/chat/hooks/useCall.ts b/frontend/src/pages/chat/hooks/useCall.ts index 71e14c8..6372c31 100644 --- a/frontend/src/pages/chat/hooks/useCall.ts +++ b/frontend/src/pages/chat/hooks/useCall.ts @@ -1,4 +1,5 @@ -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; +import { useUserStore } from "@/state/user"; import * as WebRTC from "@/core/calls/webrtc"; import { CallSignalingHandler } from "@/core/calls/signaling"; import { setCallSignalingHandler } from "@/core/websocket"; @@ -15,7 +16,7 @@ let globalRemoteScreenShareRef = createRef(); export default function useCall() { const { - chat, + call, startCall, endCall, setCallStatus, @@ -26,8 +27,9 @@ export default function useCall() { setCallSessionKeyHash, setRemoteVideoEnabled, setRemoteScreenSharing, - user - } = useAppState(); + receiveCall + } = useCallStore(); + const { user } = useUserStore(); const remoteAudioRef = globalRemoteAudioRef; const localVideoRef = globalLocalVideoRef; @@ -40,8 +42,7 @@ export default function useCall() { const signalingHandler = new CallSignalingHandler(() => ({ receiveCall: (userId: number, username: string) => { // Use the receiveCall function from state - const state = useAppState.getState(); - state.receiveCall(userId, username); + receiveCall(userId, username); }, endCall, setCallSessionKeyHash, @@ -52,8 +53,8 @@ export default function useCall() { // Set up call state change handler WebRTC.callbacks.onCallStateChange = (userId: number, state: string) => { - const call = chat.call; - if (call.remoteUserId === userId) { + const currentCall = call; + if (currentCall.remoteUserId === userId) { switch (state) { case "connecting": setCallStatus("connecting"); @@ -183,15 +184,15 @@ export default function useCall() { WebRTC.cleanup(); setCallSignalingHandler(null); }; - }, [user.authToken, chat.call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]); + }, [user.authToken, call.remoteUserId, setCallStatus, endCall, startCall, setRemoteVideoEnabled, setRemoteScreenSharing]); // Watch for session key hash changes and generate emojis useEffect(() => { - if (chat.call.sessionKeyHash && chat.call.encryptionEmojis.length === 0) { - const emojis = generateCallEmojis(chat.call.sessionKeyHash); - setCallEncryption(chat.call.sessionKeyHash, emojis); + if (call.sessionKeyHash && call.encryptionEmojis.length === 0) { + const emojis = generateCallEmojis(call.sessionKeyHash); + setCallEncryption(call.sessionKeyHash, emojis); } - }, [chat.call.sessionKeyHash, chat.call.encryptionEmojis.length, setCallEncryption]); + }, [call.sessionKeyHash, call.encryptionEmojis.length, setCallEncryption]); async function requestAudioPermissions(): Promise { try { @@ -249,12 +250,12 @@ export default function useCall() { } async function acceptCall() { - if (!chat.call.remoteUserId) { + if (!call.remoteUserId) { return; } setCallStatus("connecting"); - const success = await WebRTC.acceptCall(chat.call.remoteUserId); + const success = await WebRTC.acceptCall(call.remoteUserId); if (!success) { endCall(); @@ -262,46 +263,46 @@ export default function useCall() { } async function rejectCall() { - if (!chat.call.remoteUserId) { + if (!call.remoteUserId) { return; } - await WebRTC.rejectCall(chat.call.remoteUserId); + await WebRTC.rejectCall(call.remoteUserId); endCall(); } async function handleEndCall() { - if (chat.call.remoteUserId) { - await WebRTC.endCall(chat.call.remoteUserId); + if (call.remoteUserId) { + await WebRTC.endCall(call.remoteUserId); } endCall(); } function handleToggleMute() { - if (chat.call.remoteUserId) { - const isMuted = WebRTC.toggleMute(chat.call.remoteUserId); + if (call.remoteUserId) { + const isMuted = WebRTC.toggleMute(call.remoteUserId); // Update mute state in store - if (isMuted !== chat.call.isMuted) { + if (isMuted !== call.isMuted) { toggleMute(); } } } async function handleToggleVideo() { - if (chat.call.remoteUserId) { - const isEnabled = await WebRTC.toggleVideo(chat.call.remoteUserId); + if (call.remoteUserId) { + const isEnabled = await WebRTC.toggleVideo(call.remoteUserId); // Update video state in store - if (isEnabled !== chat.call.isVideoEnabled) { + if (isEnabled !== call.isVideoEnabled) { toggleVideo(); } } } async function handleToggleScreenShare() { - if (chat.call.remoteUserId) { - const isEnabled = await WebRTC.toggleScreenShare(chat.call.remoteUserId); + if (call.remoteUserId) { + const isEnabled = await WebRTC.toggleScreenShare(call.remoteUserId); // Update screen share state in store - if (isEnabled !== chat.call.isSharingScreen) { + if (isEnabled !== call.isSharingScreen) { toggleScreenShare(); } } @@ -336,7 +337,7 @@ export default function useCall() { } return { - call: chat.call, + call: call, initiateCall, acceptCall, rejectCall, diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 28dfb5f..579760c 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,13 +1,8 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; -import { - fetchUserPublicKey, - fetchDMHistory, - decryptDm, - sendDMViaWebSocket, - fetchDMConversations, - type DMConversationResponse -} from "@/core/api/dmApi"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; +import api from "@/core/api"; +import type { ConversationResponse } from "@/core/api/chats/dm"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -44,7 +39,8 @@ export function formatDMMessageContent( } export function useDM() { - const { user, setDmUsers, setActiveDm, addMessage, clearMessages } = useAppState(); + const { user } = useUserStore(); + const { setDmUsers, setActiveDm, addMessage, clearMessages } = useChatStore(); const [dmUsers, setDmUsersState] = useState([]); const [isLoadingUsers, setIsLoadingUsers] = useState(false); const [isLoadingHistory, setIsLoadingHistory] = useState(false); @@ -56,11 +52,11 @@ export function useDM() { try { // Get public key - const publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); + const publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken); if (!publicKey) return; // Get message history - const messages = await fetchDMHistory(dmUser.id, user.authToken, 50); + const { messages } = await api.chats.dm.fetchMessages(dmUser.id, user.authToken, 50); if (messages.length === 0) return; // Find last message @@ -68,7 +64,7 @@ export function useDM() { let lastPlaintext: string | null = null; try { - lastPlaintext = (JSON.parse(await decryptDm(lastMessage, publicKey)) as DmEncryptedJSON).data.content; + lastPlaintext = (JSON.parse(await api.chats.dm.decrypt(lastMessage, publicKey)) as DmEncryptedJSON).data.content; } catch (error) { console.error("Failed to decrypt last message:", error); } @@ -105,11 +101,11 @@ export function useDM() { usersLoadedRef.current = true; setIsLoadingUsers(true); try { - const conversations = await fetchDMConversations(user.authToken); + const conversations = await api.chats.dm.conversations(user.authToken); // Process conversations and decrypt last messages const dmUsersWithState: DMUser[] = await Promise.all( - conversations.map(async (conv: DMConversationResponse) => { + conversations.map(async (conv: ConversationResponse) => { let lastMessageContent: string | undefined = undefined; if (conv.lastMessage) { @@ -119,10 +115,10 @@ export function useDM() { ? conv.lastMessage.recipientId : conv.lastMessage.senderId; - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message - const decryptedJson = await decryptDm(conv.lastMessage, publicKey!); + const decryptedJson = await api.chats.dm.decrypt(conv.lastMessage, publicKey!); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; lastMessageContent = formatDMMessageContent(decryptedData.data.content, conv.lastMessage.senderId, user.currentUser?.id!); } @@ -141,7 +137,7 @@ export function useDM() { ); setDmUsersState(dmUsersWithState); - setDmUsers(conversations.map((conv: DMConversationResponse) => conv.user)); + setDmUsers(conversations.map((conv: ConversationResponse) => conv.user)); } catch (error) { console.error("Failed to load DM conversations:", error); @@ -161,13 +157,13 @@ export function useDM() { setIsLoadingHistory(true); try { - const messages = await fetchDMHistory(userId, user.authToken, 50); + const { messages } = await api.chats.dm.fetchMessages(userId, user.authToken, 50); const decryptedMessages: Message[] = []; let maxIncomingId = 0; for (const env of messages) { try { - const text = await decryptDm(env, publicKey); + const text = await api.chats.dm.decrypt(env, publicKey); const isAuthor = env.senderId !== userId; const username = isAuthor ? (user.currentUser?.username || "Unknown") : "Other User"; @@ -212,7 +208,7 @@ export function useDM() { if (!user.authToken) return; try { - await sendDMViaWebSocket(recipientId, publicKey, content, user.authToken); + await api.chats.dm.send(recipientId, publicKey, content, user.authToken); } catch (error) { console.error("Failed to send DM:", error); } @@ -226,7 +222,7 @@ export function useDM() { // Get public key if not already loaded let publicKey = dmUser.publicKey; if (!publicKey) { - publicKey = await fetchUserPublicKey(dmUser.id, user.authToken); + publicKey = await api.chats.dm.fetchUserPublicKey(dmUser.id, user.authToken); if (!publicKey) return; } @@ -255,7 +251,7 @@ export function useDM() { if (!user.authToken) return; try { - const conversations = await fetchDMConversations(user.authToken); + const conversations = await api.chats.dm.conversations(user.authToken); const userConversation = conversations.find(conv => conv.user.id === userId); if (userConversation) { @@ -268,10 +264,10 @@ export function useDM() { ? userConversation.lastMessage.recipientId : userConversation.lastMessage.senderId; - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { // Decrypt the last message - const decryptedJson = await decryptDm(userConversation.lastMessage, publicKey!); + const decryptedJson = await api.chats.dm.decrypt(userConversation.lastMessage, publicKey!); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; lastMessageContent = formatDMMessageContent(decryptedData.data.content, userConversation.lastMessage.senderId, user.currentUser?.id!); } @@ -295,7 +291,7 @@ export function useDM() { // If conversation no longer exists, remove the user from the list setDmUsersState(prev => prev.filter(u => u.id !== userId)); // Get current dmUsers and filter out the removed user - const currentDmUsers = useAppState.getState().chat.dmUsers; + const currentDmUsers = useChatStore.getState().dmUsers; setDmUsers(currentDmUsers.filter((u: User) => u.id !== userId)); } } catch (error) { @@ -320,9 +316,9 @@ export function useDM() { // Update unread count and last message preview try { - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { - const decryptedJson = await decryptDm(envelope, publicKey); + const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); @@ -350,9 +346,9 @@ export function useDM() { } const otherUserId = senderId === user.currentUser.id ? recipientId : senderId; try { - const publicKey = await fetchUserPublicKey(otherUserId, user.authToken!); + const publicKey = await api.chats.dm.fetchUserPublicKey(otherUserId, user.authToken!); if (publicKey) { - const decryptedJson = await decryptDm(envelope, publicKey); + const decryptedJson = await api.chats.dm.decrypt(envelope, publicKey); const decryptedData = JSON.parse(decryptedJson) as DmEncryptedJSON; const messageContent = decryptedData.data.content; const formattedMessage = formatDMMessageContent(messageContent, senderId, user.currentUser.id); diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index 3e0eb65..9c7d1af 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,10 +1,11 @@ import { useState, useCallback, useEffect } from "react"; -import { useAppState } from "@/pages/chat/state"; -import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi"; +import { useUserStore } from "@/state/user"; +import api from "@/core/api"; +import type { ProfileData } from "@/core/api/user/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { - const { user } = useAppState(); + const { user } = useUserStore(); const [profileData, setProfileData] = useState(null); const [isLoading, setIsLoading] = useState(false); const [isUpdating, setIsUpdating] = useState(false); @@ -15,7 +16,7 @@ export default function useProfile() { setIsLoading(true); try { - const data = await loadProfile(user.authToken); + const data = await api.user.profile.get(user.authToken); if (data) { setProfileData(data); } @@ -33,7 +34,7 @@ export default function useProfile() { setIsUpdating(true); try { - const success = await updateProfile(user.authToken, data); + const success = await api.user.profile.update(user.authToken, data); if (success) { // Reload profile data to get updated information await loadProfileData(); @@ -58,7 +59,7 @@ export default function useProfile() { setIsUpdating(true); try { - const result = await uploadProfilePicture(user.authToken, file); + const result = await api.user.profile.uploadPicture(user.authToken, file); if (result) { // Update profile data with new picture URL setProfileData(prev => prev ? { diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts deleted file mode 100644 index 52679f4..0000000 --- a/frontend/src/pages/chat/state.ts +++ /dev/null @@ -1,728 +0,0 @@ -import { create } from "zustand"; -import type { Message, User } from "@/core/types"; -import { request } from "@/core/websocket"; -import { MessagePanel } from "./ui/right/panels/MessagePanel"; -import { PublicChatPanel } from "./ui/right/panels/PublicChatPanel"; -import { DMPanel, type DMPanelData } from "./ui/right/panels/DMPanel"; -import { getAuthHeaders } from "@/core/api/authApi"; -import { restoreKeys } from "@/core/api/authApi"; -import { API_BASE_URL } from "@/core/config"; -import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; -import { isElectron } from "@/core/electron/electron"; -import { onlineStatusManager } from "@/core/onlineStatusManager"; -import { typingManager } from "@/core/typingManager"; - -export type ChatTabs = "chats" | "channels" | "contacts"; - -export type CallStatus = "calling" | "connecting" | "active" | "ended"; - -export interface ProfileDialogData { - userId?: number; - username?: string; - display_name?: string; - profilePicture?: string; - bio?: string; - memberSince?: string; - online?: boolean; - isOwnProfile: boolean; - verified?: boolean; - suspended?: boolean; - suspension_reason?: string | null; - deleted?: boolean; -} - -interface ActiveDM { - userId: number; - username: string; - publicKey: string | null -} - -interface CallState { - isActive: boolean; - status: CallStatus; - startTime: number | null; - isMuted: boolean; - remoteUserId: number | null; - remoteUsername: string | null; - isInitiator: boolean; - isMinimized: boolean; - sessionKeyHash: string | null; - encryptionEmojis: string[]; - isVideoEnabled: boolean; - isRemoteVideoEnabled: boolean; - isSharingScreen: boolean; - isRemoteScreenSharing: boolean; -} - -interface ChatState { - messages: Message[]; - currentChat: string; - activeTab: ChatTabs; - dmUsers: User[]; - activeDm: ActiveDM | null; - isSwitching: boolean; - setIsSwitching: (value: boolean) => void; - activePanel: MessagePanel | null; - publicChatPanel: PublicChatPanel | null; - dmPanel: DMPanel | null; - pendingPanel?: MessagePanel | null; - call: CallState; - profileDialog: ProfileDialogData | null; - onlineStatuses: Map; - typingUsers: Map; // userId -> username - dmTypingUsers: Map; -} - -export interface UserState { - currentUser: User | null; - authToken: string | null; - isSuspended: boolean; - suspensionReason: string | null; -} - -interface AppState { - // Chat state - chat: ChatState; - addMessage: (message: Message) => void; - updateMessage: (messageId: number, updatedMessage: Partial) => void; - removeMessage: (messageId: number) => void; - setCurrentChat: (chat: string) => void; - setActiveTab: (tab: ChatState["activeTab"]) => void; - setDmUsers: (users: User[]) => void; - setActiveDm: (dm: ChatState["activeDm"]) => void; - clearMessages: () => void; - setActivePanel: (panel: MessagePanel | null) => void; - setPendingPanel: (panel: MessagePanel | null) => void; - applyPendingPanel: () => void; - switchToPublicChat: (chatName: string) => Promise; - switchToDM: (dmData: DMPanelData) => Promise; - - // Call state - startCall: (userId: number, username: string) => void; - endCall: () => void; - setCallStatus: (status: CallStatus) => void; - toggleMute: () => void; - toggleCallMinimize: () => void; - receiveCall: (userId: number, username: string) => void; - setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; - setCallSessionKeyHash: (sessionKeyHash: string) => void; - toggleVideo: () => void; - toggleScreenShare: () => void; - setRemoteVideoEnabled: (enabled: boolean) => void; - setRemoteScreenSharing: (enabled: boolean) => void; - toggleCallMinimized: () => void; - - // User state - user: UserState; - setUser: (token: string, user: User) => void; - logout: () => void; - restoreUserFromStorage: () => Promise; - setSuspended: (reason: string) => void; - - // Profile dialog state - setProfileDialog: (data: ProfileDialogData | null) => void; - closeProfileDialog: () => void; - - // Online status and typing state - updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void; - addTypingUser: (userId: number, username: string) => void; - removeTypingUser: (userId: number) => void; - setDmTypingUser: (userId: number, isTyping: boolean) => void; -} - -export const useAppState = create((set, get) => ({ - // Chat state - chat: { - messages: [], - currentChat: "Общий чат", - activeTab: "chats", - dmUsers: [], - activeDm: null, - isSwitching: false, - setIsSwitching: (value: boolean) => set((state) => ({ - chat: { - ...state.chat, - isSwitching: value - } - })), - activePanel: null, - publicChatPanel: null, - dmPanel: null, - pendingPanel: null, - profileDialog: null, - call: { - isActive: false, - status: "ended", - startTime: null, - isMuted: false, - remoteUserId: null, - remoteUsername: null, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - }, - onlineStatuses: new Map(), - typingUsers: new Map(), - dmTypingUsers: new Map() - }, - addMessage: (message: Message) => set((state) => { - // Check if message already exists to prevent duplicates - const messageExists = state.chat.messages.some(msg => msg.id === message.id); - if (messageExists) { - return state; // Return unchanged state if message already exists - } - - return { - chat: { - ...state.chat, - messages: [...state.chat.messages, message] - } - }; - }), - updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ - chat: { - ...state.chat, - messages: state.chat.messages.map(msg => - msg.id === messageId ? { ...msg, ...updatedMessage } : msg - ) - } - })), - removeMessage: (messageId: number) => set((state) => ({ - chat: { - ...state.chat, - messages: state.chat.messages.filter(msg => msg.id !== messageId) - } - })), - clearMessages: () => set((state) => ({ - chat: { - ...state.chat, - messages: [] - } - })), - setCurrentChat: (chat: string) => set((state) => ({ - chat: { - ...state.chat, - currentChat: chat - } - })), - setActiveTab: (tab: ChatState["activeTab"]) => set((state) => ({ - chat: { - ...state.chat, - activeTab: tab - } - })), - setDmUsers: (users: User[]) => set((state) => ({ - chat: { - ...state.chat, - dmUsers: users - } - })), - setActiveDm: (dm: ChatState["activeDm"]) => set((state) => ({ - chat: { - ...state.chat, - activeDm: dm - } - })), - - // User state - user: { - currentUser: null, - authToken: null, - isSuspended: false, - suspensionReason: null - }, - setUser: (token: string, user: User) => { - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: user.suspended || false, - suspensionReason: user.suspension_reason || null - } - })); - - // Initialize managers with auth token - onlineStatusManager.setAuthToken(token); - typingManager.setAuthToken(token); - - // Store credentials in localStorage - try { - localStorage.setItem('authToken', token); - localStorage.setItem('currentUser', JSON.stringify(user)); - } catch (error) { - console.error('Failed to store credentials in localStorage:', error); - } - - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} - }, - logout: () => { - // Clear localStorage - try { - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); - } catch (error) { - console.error('Failed to clear localStorage:', error); - } - - // Cleanup managers - onlineStatusManager.setAuthToken(null); - typingManager.setAuthToken(null); - onlineStatusManager.cleanup(); - typingManager.cleanup(); - - set(() => ({ - user: { - currentUser: null, - authToken: null, - isSuspended: false, - suspensionReason: null - } - })); - }, - restoreUserFromStorage: async () => { - try { - const token = localStorage.getItem('authToken'); - - if (token) { - const response = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token) - }); - - if (response.ok) { - const user: User = await response.json(); - restoreKeys(); - - // Check if user is suspended - if (user.suspended) { - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: true, - suspensionReason: user.suspension_reason || null - } - })); - return; // Don't initialize managers or notifications for suspended users - } - - set(() => ({ - user: { - currentUser: user, - authToken: token, - isSuspended: false, - suspensionReason: null - } - })); - - // Initialize managers with auth token - onlineStatusManager.setAuthToken(token); - typingManager.setAuthToken(token); - - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} - - // Initialize notifications after successful credential restoration - try { - if (isSupported()) { - const initialized = await initialize(); - if (initialized) { - await subscribe(token); - - // For Electron, start the notification receiver - if (isElectron) { - await startElectronReceiver(); - } - } - } - } catch (e) { - console.error("Notification setup failed (restored):", e); - } - } else { - throw new Error("Unable to authenticate"); - } - } - } catch (error) { - console.error('Failed to restore user from localStorage:', error); - // Clear invalid data - localStorage.removeItem('authToken'); - localStorage.removeItem('currentUser'); - } - }, - - // Panel management - setActivePanel: (panel: MessagePanel | null) => { - const state = get(); - // Deactivate the current panel before switching - if (state.chat.activePanel && state.chat.activePanel !== panel) { - state.chat.activePanel.deactivate(); - } - return set((state) => ({ - chat: { - ...state.chat, - activePanel: panel - } - })); - }, - // Stash a panel to be applied after switch-out animation ends - setPendingPanel: (panel: MessagePanel | null) => set((state) => ({ - chat: { - ...state.chat, - pendingPanel: panel - } - })), - // Apply pending panel atomically and update related fields - applyPendingPanel: () => { - const state = get(); - // Deactivate the current panel before switching - if (state.chat.activePanel) { - state.chat.activePanel.deactivate(); - } - return set((state) => ({ - chat: { - ...state.chat, - activePanel: state.chat.pendingPanel || state.chat.activePanel, - // when switching to public chat, keep reference if type matches - publicChatPanel: (state.chat.pendingPanel instanceof PublicChatPanel) - ? (state.chat.pendingPanel as PublicChatPanel) - : state.chat.publicChatPanel, - dmPanel: (state.chat.pendingPanel instanceof DMPanel) - ? (state.chat.pendingPanel as DMPanel) - : state.chat.dmPanel, - // update currentChat from panel title if available - currentChat: state.chat.pendingPanel ? state.chat.pendingPanel.getState().title || state.chat.currentChat : state.chat.currentChat, - pendingPanel: null - } - })); - }, - - switchToPublicChat: async (chatName: string) => { - const { user, chat } = get(); - - if (!user.authToken) return; - - // Start chat switching animation - chat.setIsSwitching(true); - - // Create or get public chat panel - let publicChatPanel = chat.publicChatPanel; - if (!publicChatPanel) { - publicChatPanel = new PublicChatPanel(chatName, user); - } else { - publicChatPanel.setChatName(chatName); - publicChatPanel.setAuthToken(user.authToken); - // Reset messages for the new chat - publicChatPanel.clearMessages(); - } - - // Activate panel - await publicChatPanel.activate(); - - // Defer panel swap until animation switch-out completes - set((state) => ({ - chat: { - ...state.chat, - pendingPanel: publicChatPanel, - activeTab: "chats" - } - })); - - // Let MessagePanelRenderer handle the animation timing completely - // It will set isChatSwitching to false when the fadeInDown animation completes - }, - - switchToDM: async (dmData: DMPanelData) => { - const { user, chat } = get(); - - if (!user.authToken) return; - - // Start chat switching animation - chat.setIsSwitching(true); - - // Create or get DM panel - let dmPanel = chat.dmPanel; - if (!dmPanel) { - dmPanel = new DMPanel(user); - } else { - dmPanel.setAuthToken(user.authToken); - // Reset messages for the new DM - dmPanel.clearMessages(); - } - - // Set DM data - dmPanel.setDMData(dmData); - - // Activate panel - await dmPanel.activate(); - - // Defer panel swap until animation switch-out completes - set((state) => ({ - chat: { - ...state.chat, - pendingPanel: dmPanel, - activeDm: { - userId: dmData.userId, - username: dmData.username, - publicKey: dmData.publicKey - }, - activeTab: "chats" - } - })); - - // Let MessagePanelRenderer handle the animation timing completely - // It will set isChatSwitching to false when the fadeInDown animation completes - }, - - // Call state management - startCall: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: true, - status: "calling", - startTime: null, - isMuted: false, - remoteUserId: userId, - remoteUsername: username, - isInitiator: true, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - endCall: () => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: false, - status: "ended", - startTime: null, - isMuted: false, - remoteUserId: null, - remoteUsername: null, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - setCallStatus: (status: CallStatus) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - status, - startTime: status === "active" && !state.chat.call.startTime ? Date.now() : state.chat.call.startTime - } - } - })), - - toggleMute: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMuted: !state.chat.call.isMuted - } - } - })), - - toggleCallMinimize: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMinimized: !state.chat.call.isMinimized - } - } - })), - - receiveCall: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - isActive: true, - status: "calling", - startTime: null, - isMuted: false, - remoteUserId: userId, - remoteUsername: username, - isInitiator: false, - isMinimized: false, - sessionKeyHash: null, - encryptionEmojis: [], - isVideoEnabled: false, - isRemoteVideoEnabled: false, - isSharingScreen: false, - isRemoteScreenSharing: false - } - } - })), - - setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - sessionKeyHash, - encryptionEmojis - } - } - })), - - setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - sessionKeyHash - } - } - })), - - toggleVideo: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isVideoEnabled: !state.chat.call.isVideoEnabled - } - } - })), - - toggleScreenShare: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isSharingScreen: !state.chat.call.isSharingScreen - } - } - })), - - setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isRemoteVideoEnabled: enabled - } - } - })), - - setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isRemoteScreenSharing: enabled - } - } - })), - toggleCallMinimized: () => set((state) => ({ - chat: { - ...state.chat, - call: { - ...state.chat.call, - isMinimized: !state.chat.call.isMinimized - } - } - })), - - // Profile dialog state management - setProfileDialog: (data: ProfileDialogData | null) => set((state) => ({ - chat: { - ...state.chat, - profileDialog: data - } - })), - - closeProfileDialog: () => set((state) => ({ - chat: { - ...state.chat, - profileDialog: null - } - })), - - // Online status and typing state management - updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({ - chat: { - ...state.chat, - onlineStatuses: new Map(state.chat.onlineStatuses).set(userId, { online, lastSeen }) - } - })), - - addTypingUser: (userId: number, username: string) => set((state) => ({ - chat: { - ...state.chat, - typingUsers: new Map(state.chat.typingUsers).set(userId, username) - } - })), - - removeTypingUser: (userId: number) => set((state) => { - const newTypingUsers = new Map(state.chat.typingUsers); - newTypingUsers.delete(userId); - return { - chat: { - ...state.chat, - typingUsers: newTypingUsers - } - }; - }), - - setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => { - const newDmTypingUsers = new Map(state.chat.dmTypingUsers); - if (isTyping) { - newDmTypingUsers.set(userId, true); - } else { - newDmTypingUsers.delete(userId); - } - return { - chat: { - ...state.chat, - dmTypingUsers: newDmTypingUsers - } - }; - }), - - setSuspended: (reason: string) => set((state) => ({ - user: { - ...state.user, - isSuspended: true, - suspensionReason: reason - } - })) -})); \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 0a67f6f..89a9795 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -4,15 +4,17 @@ import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { CallWindow } from "./right/calls/CallWindow"; import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; -import { useAppState } from "@/pages/chat/state"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; +import api from "@/core/api"; import styles from "@/pages/chat/css/layout.module.scss"; export default function ChatPage() { const { navigate: navigateDownloadApp } = useDownloadAppScreen(); const location = useLocation(); const navigate = useNavigate(); - const { user, setProfileDialog } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const processedProfile = useRef(null); // Handle profile links ONLY from navigation state (from SmartCatchAll) @@ -41,10 +43,10 @@ export default function ChatPage() { if (profileInfo.userId) { // Fetch by user ID - userProfile = await fetchUserProfileById(user.authToken, profileInfo.userId); + userProfile = await api.user.profile.fetchById(user.authToken, profileInfo.userId); } else if (profileInfo.username) { // Fetch by username - userProfile = await fetchUserProfile(user.authToken, profileInfo.username); + userProfile = await api.user.profile.fetchByUsername(user.authToken, profileInfo.username); } if (userProfile) { @@ -64,7 +66,7 @@ export default function ChatPage() { } handleProfileLink(); - }, [location.state, user.authToken, user.currentUser?.id, setProfileDialog, navigate, location.pathname]); + }, [location.state, user.authToken, user.currentUser?.id, navigate, location.pathname]); if (navigateDownloadApp) return navigateDownloadApp; diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index b576348..0a52dab 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -1,10 +1,11 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; -import { useAppState } from "@/pages/chat/state"; -import type { ProfileDialogData } from "@/pages/chat/state"; +import { useProfileStore } from "@/state/profile"; +import { useUserStore } from "@/state/user"; +import type { ProfileDialogData } from "@/state/types"; import defaultAvatar from "@/images/default-avatar.png"; import { confirm } from "mdui/functions/confirm"; import { prompt } from "mdui/functions/prompt"; -import { updateProfile, uploadProfilePicture, fetchUserProfileById } from "@/core/api/profileApi"; +import api from "@/core/api"; import { RichTextArea } from "@/core/components/RichTextArea"; import { StatusBadge } from "@/core/components/StatusBadge"; import { VerifyButton } from "@/core/components/VerifyButton"; @@ -70,7 +71,8 @@ function Section({ type, icon, label, error, value, onChange, readOnly, placehol } export function ProfileDialog() { - const { chat, user, closeProfileDialog, setUser } = useAppState(); + const { profileDialog, closeProfileDialog } = useProfileStore(); + const { user, setUser } = useUserStore(); const [isOpen, setIsOpen] = useState(false); const [originalData, setOriginalData] = useState(null); const [currentData, setCurrentData] = useState(null); @@ -80,13 +82,13 @@ export function ProfileDialog() { // Handle dialog open/close based on state useEffect(() => { - if (chat.profileDialog && !isOpen) { + if (profileDialog && !isOpen) { // Fetch fresh data when opening dialog - fetchFreshProfileData(chat.profileDialog); - } else if (!chat.profileDialog && isOpen) { + fetchFreshProfileData(profileDialog); + } else if (!profileDialog && isOpen) { setIsOpen(false); } - }, [chat.profileDialog, isOpen]); + }, [profileDialog, isOpen]); async function fetchFreshProfileData(profileData: ProfileDialogData) { if (!user.authToken) return; @@ -96,7 +98,7 @@ export function ProfileDialog() { // If it's not the public chat and has a user ID, fetch fresh data if (profileData.userId && profileData.username !== "Общий чат") { - const userProfile = await fetchUserProfileById(user.authToken, profileData.userId); + const userProfile = await api.user.profile.fetchById(user.authToken, profileData.userId); if (userProfile) { freshData = { ...userProfile, @@ -283,7 +285,7 @@ export function ProfileDialog() { } if (Object.keys(updateData).length > 0) { - await updateProfile(user.authToken, updateData); + await api.user.profile.update(user.authToken, updateData); } // Update profile picture if changed @@ -292,7 +294,7 @@ export function ProfileDialog() { if (currentData.profilePicture.startsWith("data:")) { const response = await fetch(currentData.profilePicture); const blob = await response.blob(); - await uploadProfilePicture(user.authToken, blob); + await api.user.profile.uploadPicture(user.authToken, blob); } } @@ -349,37 +351,20 @@ export function ProfileDialog() { }); if (reason) { - const response = await fetch(`/api/user/${currentData.userId}/suspend`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - }, - body: JSON.stringify({ reason }) - }); - - if (response.ok) { + const result = await api.moderation.users.suspend(currentData.userId, reason, user.authToken!); + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to suspend user:", error); + console.error("Failed to suspend user"); } } } else { // Unsuspend user - const response = await fetch(`/api/user/${currentData.userId}/unsuspend`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - } - }); - - if (response.ok) { + const result = await api.moderation.users.unsuspend(currentData.userId, user.authToken!); + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to unsuspend user:", error); + console.error("Failed to unsuspend user"); } } } catch (error) { @@ -398,19 +383,12 @@ export function ProfileDialog() { cancelText: "Cancel" }); - const response = await fetch(`/api/user/${currentData.userId}/delete`, { - method: "POST", - headers: { - "Authorization": `Bearer ${user.authToken}`, - "Content-Type": "application/json" - } - }); + const result = await api.moderation.users.deleteUser(currentData.userId, user.authToken!); - if (response.ok) { + if (result) { closeProfileDialog(); } else { - const error = await response.json(); - console.error("Failed to delete user:", error); + console.error("Failed to delete user"); } } catch (error) { // User cancelled or error occurred diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index dac200b..d975b0b 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -2,16 +2,19 @@ import { PRODUCT_NAME } from "@/core/config"; import useProfile from "@/pages/chat/hooks/useProfile"; import defaultAvatar from "@/images/default-avatar.png"; import { useState } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { MinimizedCallBar } from "@/pages/chat/ui/right/calls/MinimizedCallBar"; import styles from "@/pages/chat/css/left-panel.module.scss"; +import logoIcon from "@/images/logo.svg"; -export function ChatHeader() { +export function ChatHeader({ headerRef }: { headerRef?: React.RefObject }) { const { profileData } = useProfile(); - const { setProfileDialog, user } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); - const handleProfileClick = () => { + function handleProfileClick() { setProfileDialog({ userId: user.currentUser?.id, username: profileData?.username || "Пользователь", @@ -26,7 +29,8 @@ export function ChatHeader() { return ( <> -
+
+ Logo
{PRODUCT_NAME}
diff --git a/frontend/src/pages/chat/ui/left/ChatTabs.tsx b/frontend/src/pages/chat/ui/left/ChatTabs.tsx deleted file mode 100644 index 6560205..0000000 --- a/frontend/src/pages/chat/ui/left/ChatTabs.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { useAppState, type ChatTabs } from "@/pages/chat/state"; -import { UnifiedChatsList } from "./UnifiedChatsList"; -import { MaterialTab, MaterialTabPanel, MaterialTabs } from "@/utils/material"; -import styles from "@/pages/chat/css/left-panel.module.scss"; - -export function ChatTabs() { - const { chat, setActiveTab } = useAppState(); - - return ( -
- setActiveTab(e.target.value as ChatTabs)}> - - Чаты - - - Каналы - - - Контакты - - - - - - Скоро будет... - Скоро будет... - -
- ); -} diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index c191875..1f0fb9d 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -1,22 +1,21 @@ -import { useAppState } from "@/pages/chat/state"; -import { useState } from "react"; +import { useUserStore } from "@/state/user"; +import { useRef, useState } from "react"; import { SettingsDialog } from "./settings/SettingsDialog"; import { UsernameSearch } from "./UsernameSearch"; -import { ChatTabs } from "./ChatTabs"; +import { UnifiedChatsList } from "./UnifiedChatsList"; import { ChatHeader } from "./ChatHeader"; -import { MaterialBottomAppBar, MaterialFab, MaterialIconButton } from "@/utils/material"; +import { MaterialBottomAppBar, MaterialFab, MaterialIconButton, type MDUIBottomAppBar } from "@/utils/material"; import styles from "@/pages/chat/css/left-panel.module.scss"; -function BottomAppBar() { +function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject }) { const [settingsOpen, onSettingsOpenChange] = useState(false); - const { logout } = useAppState(); + const { logout } = useUserStore(); return ( <> - + onSettingsOpenChange(true)} /> - -
+
(null); + const headerRef = useRef(null); + const bottomAppBarRef = useRef(null); + return ( -
- +
+
- +
- - + +
); } diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 3a4f709..485db7a 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -1,9 +1,8 @@ -import { useState, useEffect, useCallback } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useState, useEffect, useCallback, useMemo } from "react"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; -import { fetchUserPublicKey } from "@/core/api/dmApi"; +import api from "@/core/api"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -36,123 +35,89 @@ interface DMConversation { type ChatItem = PublicChat | DMConversation; +const PUBLIC_CHAT: PublicChat = { + id: "general", + name: "Общий чат", + type: "public" +}; + export function UnifiedChatsList() { - const { user, switchToPublicChat, switchToDM, chat } = useAppState(); + const { user } = useUserStore(); + const { switchToPublicChat, switchToDM, activeTab } = useChatStore(); const { dmUsers, isLoadingUsers, loadUsers } = useDM(); - - const [publicChats] = useState([ - { id: "general", name: "Общий чат", type: "public" }, - { id: "general2", name: "Общий чат 2", type: "public" } - ]); const [lastMessages, setLastMessages] = useState>({}); - const [allChats, setAllChats] = useState([]); - // Load public chat last messages const loadLastMessages = useCallback(async () => { if (!user.authToken) return; try { - const response = await fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders(user.authToken) - }); - - if (response.ok) { - const data = await response.json(); - if (data.messages && data.messages.length > 0) { - const lastMessage = data.messages[data.messages.length - 1]; - - setLastMessages({ - general: lastMessage, - general2: lastMessage - }); - } + const { messages } = await api.chats.general.fetchMessages(user.authToken, 1); + if (messages?.length > 0) { + const lastMessage = messages[messages.length - 1]; + setLastMessages({ general: lastMessage }); } } catch (error) { console.error("Error loading last messages:", error); } }, [user.authToken]); - // Load DM users when chats tab is active useEffect(() => { - if (chat.activeTab === "chats") { + if (activeTab === "chats") { loadUsers(); loadLastMessages(); } - }, [chat.activeTab, loadUsers, loadLastMessages]); + }, [activeTab, loadUsers, loadLastMessages]); - // Combine public chats and DMs into one list - useEffect(() => { - const publicChatItems: ChatItem[] = publicChats.map(chat => ({ - ...chat, - lastMessage: lastMessages[chat.id] - })); + const allChats = useMemo(() => { + return [ + ...dmUsers.map((user: DMUser) => ({ + ...user, + userId: user.id, + type: "dm" as const + })), + { + ...PUBLIC_CHAT, + lastMessage: lastMessages[PUBLIC_CHAT.id] + } + ]; + }, [lastMessages, dmUsers]); - const dmChatItems: ChatItem[] = dmUsers.map((user: DMUser) => ({ - id: user.id, - userId: user.id, // Add userId field - username: user.username, - display_name: user.display_name, - profile_picture: user.profile_picture, - online: user.online, - type: "dm" as const, - lastMessage: user.lastMessage, - unreadCount: user.unreadCount, - publicKey: user.publicKey - })); - - // Combine and sort by last message timestamp (DMs first, then public chats) - const combined = [...dmChatItems, ...publicChatItems]; - setAllChats(combined); - }, [publicChats, lastMessages, dmUsers]); - - // WebSocket listener for public chat message updates useEffect(() => { if (!websocket) return; - const handleWebSocketMessage = (e: MessageEvent) => { + function handleWebSocketMessage(e: MessageEvent) { try { const msg = JSON.parse(e.data); if (msg.type === "newMessage") { const newMessage = msg.data as Message; - // Update all public chats with the new message - setLastMessages(prev => { - const updated = { ...prev }; - publicChats.forEach(chat => { - updated[chat.id] = newMessage; - }); - return updated; - }); + setLastMessages(prev => ({ + ...prev, + [PUBLIC_CHAT.id]: newMessage + })); } else if (msg.type === "messageEdited") { const editedMessage = msg.data as Message; - // Update only if the edited message is the current last message setLastMessages(prev => { - const updated = { ...prev }; - publicChats.forEach(chat => { - if (updated[chat.id]?.id === editedMessage.id) { - updated[chat.id] = editedMessage; - } - }); - return updated; + if (prev[PUBLIC_CHAT.id]?.id === editedMessage.id) { + return { + ...prev, + [PUBLIC_CHAT.id]: editedMessage + }; + } + return prev; }); } else if (msg.type === "messageDeleted") { const deletedMessageId = msg.data?.message_id; - let needsReload = false; - setLastMessages(prev => { - const updated = { ...prev }; - publicChats.forEach(chat => { - if (updated[chat.id]?.id === deletedMessageId) { - updated[chat.id] = undefined; - needsReload = true; - } - }); - return updated; + if (prev[PUBLIC_CHAT.id]?.id === deletedMessageId) { + loadLastMessages(); + return { + ...prev, + [PUBLIC_CHAT.id]: undefined + }; + } + return prev; }); - - if (needsReload) { - loadLastMessages(); - } } } catch (error) { console.error("Failed to handle WebSocket message in UnifiedChatsList:", error); @@ -161,58 +126,45 @@ export function UnifiedChatsList() { websocket.addEventListener("message", handleWebSocketMessage); return () => websocket.removeEventListener("message", handleWebSocketMessage); - }, [publicChats, loadLastMessages]); + }, [loadLastMessages]); - // Subscribe to online status for all DM users useEffect(() => { - const dmUsers = allChats.filter(chat => chat.type === "dm") as DMConversation[]; - - // Subscribe to all DM users dmUsers.forEach(dmUser => { onlineStatusManager.subscribe(dmUser.id); }); - // Cleanup function to unsubscribe from all users return () => { dmUsers.forEach(dmUser => { onlineStatusManager.unsubscribe(dmUser.id); }); }; - }, [allChats]); + }, [dmUsers]); function formatPublicChatMessage(chatId: string): string { const lastMessage = lastMessages[chatId]; - if (!lastMessage) { - return ""; - } + if (!lastMessage) return ""; const isCurrentUser = lastMessage.user_id === user.currentUser?.id; const prefix = isCurrentUser ? "Вы: " : `${lastMessage.username}: `; - - const maxContentLength = 50 - prefix.length; - const content = lastMessage.content.length > maxContentLength - ? lastMessage.content.substring(0, maxContentLength) + "..." + const maxLength = 50 - prefix.length; + const content = lastMessage.content.length > maxLength + ? lastMessage.content.substring(0, maxLength) + "..." : lastMessage.content; return prefix + content; - } - - async function handlePublicChatClick(chatName: string) { - await switchToPublicChat(chatName); - } + }; async function handleDMClick(dmConversation: DMConversation) { if (!dmConversation.publicKey) { - const authToken = useAppState.getState().user.authToken; + const authToken = useUserStore.getState().user.authToken; if (!authToken) return; - const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); - if (publicKey) { - dmConversation.publicKey = publicKey; - } else { + const publicKey = await api.chats.dm.fetchUserPublicKey(dmConversation.id, authToken); + if (!publicKey) { console.error("Failed to get public key for user:", dmConversation.id); return; } + dmConversation.publicKey = publicKey; } await switchToDM({ @@ -222,26 +174,27 @@ export function UnifiedChatsList() { profilePicture: dmConversation.profile_picture, online: dmConversation.online || false }); - } + }; if (isLoadingUsers) { return ; } return ( - + {allChats.map((chat) => { if (chat.type === "public") { + const formattedMessage = formatPublicChatMessage(chat.id); return ( handlePublicChatClick(chat.name)} + onClick={() => switchToPublicChat(chat.name)} style={{ cursor: "pointer" }} > - {formatPublicChatMessage(chat.id) && ( + {formattedMessage && ( - {formatPublicChatMessage(chat.id)} + {formattedMessage} )} ); - } else { - return ( - handleDMClick(chat)} - style={{ cursor: "pointer" }} - > -
- {chat.display_name} - -
- - {chat.lastMessage || "Нет сообщений"} - -
- {chat.display_name} { - e.target.src = defaultAvatar; - }} - /> - -
- {chat.unreadCount > 0 && ( - - {chat.unreadCount} - - )} -
- ); } + + return ( + handleDMClick(chat)} + style={{ cursor: "pointer" }} + > +
+ {chat.display_name} + +
+ + {chat.lastMessage || "Нет сообщений"} + +
+ {chat.display_name} { + e.target.src = defaultAvatar; + }} + /> + +
+ {chat.unreadCount > 0 && ( + + {chat.unreadCount} + + )} +
+ ); })}
); diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index b27c861..7184e46 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,6 +1,7 @@ import { useState, useEffect, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; -import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; +import api from "@/core/api"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -8,7 +9,7 @@ import { OnlineIndicator } from "@/pages/chat/ui/right/OnlineIndicator"; import { OnlineStatus } from "@/pages/chat/ui/right/OnlineStatus"; import defaultAvatar from "@/images/default-avatar.png"; import SearchBar from "@/core/components/SearchBar"; -import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; +import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem, type MDUIBottomAppBar } from "@/utils/material"; import styles from "@/pages/chat/css/left-panel.module.scss"; interface SearchUser extends User { @@ -16,8 +17,15 @@ interface SearchUser extends User { verified?: boolean; } -export function UsernameSearch() { - const { user, switchToDM, chat } = useAppState(); +export interface UsernameSearchProps { + containerRef: React.RefObject; + headerRef?: React.RefObject; + bottomAppBarRef?: React.RefObject; +} + +export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) { + const { user } = useUserStore(); + const { switchToDM, activeDm } = useChatStore(); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); @@ -37,7 +45,7 @@ export function UsernameSearch() { const newTimeout = setTimeout(async () => { if (user.authToken) { try { - const users = await searchUsers(searchQuery, user.authToken); + const users = await api.user.search.searchUsers(searchQuery, user.authToken); setSearchResults(users); } catch (error) { console.error("Search failed:", error); @@ -62,7 +70,7 @@ export function UsernameSearch() { // Subscribe to online status for all search results useEffect(() => { - const activeDmUserId = chat.activeDm?.userId; + const activeDmUserId = activeDm?.userId; const switchingToUserId = switchingToUserIdRef.current; const currentSearchResultIds = new Set(searchResults.map(u => u.id)); const previousSearchResultIds = new Set(previousSearchResultIdsRef.current); @@ -95,12 +103,12 @@ export function UsernameSearch() { // Clear the ref if the user is now the active DM (state has updated) const finalSwitchingToUserId = switchingToUserIdRef.current; - const finalActiveDmUserId = chat.activeDm?.userId; + const finalActiveDmUserId = activeDm?.userId; if (finalSwitchingToUserId && finalSwitchingToUserId === finalActiveDmUserId) { switchingToUserIdRef.current = null; } }; - }, [searchResults, chat.activeDm?.userId]); + }, [searchResults, activeDm?.userId]); async function handleUserClick(searchUser: SearchUser) { @@ -110,7 +118,7 @@ export function UsernameSearch() { let publicKey = searchUser.publicKey; if (!publicKey) { - const fetchedPublicKey = await fetchUserPublicKey(searchUser.id, user.authToken); + const fetchedPublicKey = await api.chats.dm.fetchUserPublicKey(searchUser.id, user.authToken); publicKey = fetchedPublicKey; } @@ -165,6 +173,9 @@ export function UsernameSearch() { icon="arrow_back--outlined" /> ) : "search--outlined"} + containerRef={containerRef} + headerRef={headerRef} + bottomAppBarRef={bottomAppBarRef} > {isSearching && (
diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 16e39a9..855d0fc 100644 --- a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -1,6 +1,6 @@ import { MaterialList, MaterialListItem } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; -import { deleteAccount } from "@/core/api/securityApi"; +import { useUserStore } from "@/state/user"; +import api from "@/core/api"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; @@ -9,7 +9,7 @@ interface AccountPanelProps { } export function AccountPanel({ onClose }: AccountPanelProps) { - const { user, logout } = useAppState(); + const { user, logout } = useUserStore(); const authToken = user?.authToken; async function handleDeleteAccount() { @@ -23,7 +23,7 @@ export function AccountPanel({ onClose }: AccountPanelProps) { cancelText: "Cancel" }); - await deleteAccount(authToken); + await api.user.auth.deleteAccount(authToken); logout(); onClose(); } catch (error) { diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index 8b6f9f1..c56058f 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -1,13 +1,13 @@ import { useState } from "react"; import { StyledDialog } from "@/core/components/StyledDialog"; import type { DialogProps } from "@/core/types"; -import { useAppState } from "@/pages/chat/state"; -import { changePassword } from "@/core/api/securityApi"; +import { useUserStore } from "@/state/user"; +import api from "@/core/api"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogProps) { - const { user } = useAppState(); + const { user } = useUserStore(); const [current, setCurrent] = useState(""); const [next, setNext] = useState(""); @@ -29,7 +29,7 @@ export default function ChangePasswordDialog({ isOpen, onOpenChange }: DialogPro if (!current || !next || next !== confirm) return; setBusy(true); try { - await changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll); + await api.user.auth.changePassword(user.authToken, user.currentUser?.username, current, next, logoutAll); setCurrent(""); setNext(""); setConfirm(""); diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index ac9dbc7..810611c 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -1,13 +1,14 @@ import { useState, useEffect } from "react"; import { useImmer } from "use-immer"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; -import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/devicesApi"; +import { useUserStore } from "@/state/user"; +import api from "@/core/api"; +import type { DeviceInfo } from "@/core/api/user/devices"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function DevicesPanel() { - const { user } = useAppState(); + const { user } = useUserStore(); const authToken = user?.authToken ?? null; const [devices, updateDevices] = useImmer([]); const [devicesLoading, setDevicesLoading] = useState(false); @@ -24,7 +25,7 @@ export function DevicesPanel() { setDevicesLoading(true); try { - const deviceList = await listDevices(authToken); + const deviceList = await api.user.devices.list(authToken); updateDevices(deviceList); } catch (error) { console.error("Failed to load devices:", error); @@ -48,7 +49,7 @@ export function DevicesPanel() { draft.add(sessionId); }); - await revokeDevice(authToken, sessionId); + await api.user.devices.revoke(authToken, sessionId); await loadDevices(); } catch (error) { if (error !== "cancelled") { @@ -72,7 +73,7 @@ export function DevicesPanel() { cancelText: "Cancel" }); - await logoutAllOtherDevices(authToken); + await api.user.devices.revokeAll(authToken); await loadDevices(); } catch (error) { if (error !== "cancelled") { diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index d5f0931..ea84bf7 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -1,14 +1,13 @@ import { useState, useRef } from "react"; import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; +import api from "@/core/api"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { - const { user } = useAppState(); + const { user } = useUserStore(); const authToken = user?.authToken ?? null; const [pushEnabled, setPushEnabled] = useState(false); const [loading, setLoading] = useState(false); @@ -74,14 +73,7 @@ export function NotificationsPanel() { } // Then unsubscribe from server - const response = await fetch(`${API_BASE_URL}/push/unsubscribe`, { - method: "DELETE", - headers: getAuthHeaders(authToken) - }); - - if (!response.ok) { - throw new Error("Failed to unsubscribe from push notifications"); - } + await api.push.subscription.unsubscribe(authToken); // After unsubscribing, permission is still granted but we're not subscribed // So we keep the state as disabled (false) diff --git a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx index a41c7b5..35a1ef3 100644 --- a/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx +++ b/frontend/src/pages/chat/ui/right/ChatInputWrapper.tsx @@ -7,6 +7,7 @@ import { useImmer } from "use-immer"; import { EmojiMenu } from "./EmojiMenu"; import { MaterialIcon, MaterialIconButton } from "@/utils/material"; import styles from "@/pages/chat/css/ChatInput.module.scss"; +import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss"; import { alert } from "mdui/functions/alert"; interface ChatInputWrapperProps { @@ -161,9 +162,9 @@ export function ChatInputWrapper( >
- - {editingMessage!.username} - {editingMessage!.content} + + {editingMessage!.username} + {editingMessage!.content}
@@ -181,9 +182,9 @@ export function ChatInputWrapper( >
- - {replyTo!.username} - {replyTo!.content} + + {replyTo!.username} + {replyTo!.content}
diff --git a/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx b/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx index 09b5635..fe034da 100644 --- a/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMainHeader.tsx @@ -1,8 +1,8 @@ -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; import defaultAvatar from "@/images/default-avatar.png"; export function ChatMainHeader() { - const { currentChat } = useAppState().chat; + const { currentChat } = useChatStore(); return (
diff --git a/frontend/src/pages/chat/ui/right/ChatMessages.tsx b/frontend/src/pages/chat/ui/right/ChatMessages.tsx index 8447153..b3c9421 100644 --- a/frontend/src/pages/chat/ui/right/ChatMessages.tsx +++ b/frontend/src/pages/chat/ui/right/ChatMessages.tsx @@ -1,5 +1,5 @@ import { Message } from "./Message"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import type { Message as MessageType } from "@/core/types"; import { MessageContextMenu, type ContextMenuState } from "./MessageContextMenu"; import { useState, type ReactNode } from "react"; @@ -20,7 +20,7 @@ interface ChatMessagesProps { } export function ChatMessages({ messages = [], children, isDm = false, onReplySelect, onEditSelect, onDelete, onRetryMessage, dmRecipientPublicKey }: ChatMessagesProps) { - const { user } = useAppState(); + const { user } = useUserStore(); // Context menu state const [contextMenu, setContextMenu] = useState({ diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 9423457..8d9b688 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -3,14 +3,12 @@ import type { Attachment, Message as MessageType, Reaction } from "@/core/types" import defaultAvatar from "@/images/default-avatar.png"; import Quote from "@/core/components/Quote"; import { parse } from "marked"; -import DOMPurify from "dompurify"; +import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; -import { getCurrentKeys } from "@/core/api/authApi"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { getAuthHeaders } from "@/core/api/authApi"; -import { useAppState } from "@/pages/chat/state"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/profileApi"; +import api from "@/core/api"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; @@ -18,6 +16,7 @@ import { createPortal } from "react-dom"; import { parseProfileLink } from "@/core/profileLinks"; import { MaterialCircularProgress, MaterialIconButton, MaterialList, MaterialListItem } from "@/utils/material"; import styles from "@/pages/chat/css/Message.module.scss"; +import replyPreviewStyles from "@/pages/chat/css/reply-preview.module.scss"; interface MessageReactionsProps { reactions?: Reaction[]; @@ -26,7 +25,7 @@ interface MessageReactionsProps { } function Reactions({ reactions, onReactionClick, messageId }: MessageReactionsProps) { - const { user } = useAppState(); + const { user } = useUserStore(); const [visibleReactions, setVisibleReactions] = useState([]); const [animatingReactions, setAnimatingReactions] = useState>(new Set()); const [isVisible, setIsVisible] = useState(false); @@ -163,14 +162,15 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD endRect: Rect; } | null>(null); const [isAnimatingOpen, setIsAnimatingOpen] = useState(false); - const { user, setProfileDialog } = useAppState(); + const { user } = useUserStore(); + const { setProfileDialog } = useProfileStore(); const imageRefs = useRef>(new Map()); const dmEnvelope = message.runtimeData?.dmEnvelope; const formattedMessage = useMemo(() => { // First, temporarily replace existing fromchat.ru links to avoid conflicts const linkPlaceholders: string[] = []; - let content = message.content.replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { + let content = escapeHtml(message.content).replace(/https?:\/\/fromchat\.ru\/@[a-zA-Z0-9_.-]+/g, (match) => { const placeholder = `__LINK_PLACEHOLDER_${linkPlaceholders.length}__`; linkPlaceholders.push(match); return placeholder; @@ -186,8 +186,10 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD content = content.replace(`__LINK_PLACEHOLDER_${index}__`, link); }); + const rendered = parse(content, { async: false }).trim(); + return { - __html: DOMPurify.sanitize(parse(content, { async: false })).trim() + __html: rendered }; }, [message.content, styles.mentionLink]); @@ -220,14 +222,14 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD // no-op decrypt indicator removed from UI // Fetch encrypted file const response = await fetch(file.path, { - headers: getAuthHeaders(user.authToken!) + headers: api.user.auth.getAuthHeaders(user.authToken!) }); if (!response.ok) throw new Error("Failed to fetch file"); const encryptedData = await response.arrayBuffer(); // Get current user's keys - const keys = getCurrentKeys(); + const keys = api.user.auth.getCurrentKeys(); if (!keys) throw new Error("Keys not initialized"); // Derive shared secret with the recipient's public key @@ -340,7 +342,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD // Fetch with credentials/headers when not a blob URL const response = await fetch(src, { - headers: user.authToken ? getAuthHeaders(user.authToken) : undefined, + headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined, credentials: "include" }); if (!response.ok) throw new Error("Failed to download image"); @@ -378,7 +380,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD // If not decrypted or public file, fetch with credentials/headers const response = await fetch(file.path, { - headers: user.authToken ? getAuthHeaders(user.authToken) : undefined, + headers: user.authToken ? api.user.auth.getAuthHeaders(user.authToken) : undefined, credentials: "include" }); if (!response.ok) throw new Error("Failed to download file"); @@ -402,7 +404,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD if (!user.authToken || !message.user_id) return; try { - const userProfile = await fetchUserProfileById(user.authToken, message.user_id); + const userProfile = await api.user.profile.fetchById(user.authToken, message.user_id); if (userProfile) { setProfileDialog({ ...userProfile, @@ -431,9 +433,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD let userProfile; if (profileLink.userId) { - userProfile = await fetchUserProfileById(user.authToken, profileLink.userId); - } else if (profileLink.username) { - userProfile = await fetchUserProfile(user.authToken, profileLink.username); + userProfile = await api.user.profile.fetchById(user.authToken, profileLink.userId); + } else if (profileLink.username) { + userProfile = await api.user.profile.fetchByUsername(user.authToken, profileLink.username); } if (userProfile) { @@ -481,7 +483,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD {!isAuthor && !isDm && (
{message.username} { e.target.src = defaultAvatar; @@ -505,9 +507,9 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD )} {message.reply_to && ( - - {message.reply_to.username} - {message.reply_to.content} + + {message.reply_to.username} + {message.reply_to.content} )} diff --git a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx index 8323483..099cafc 100644 --- a/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx +++ b/frontend/src/pages/chat/ui/right/MessageContextMenu.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import type { Message, Size2D } from "@/core/types"; import { EmojiMenu } from "./EmojiMenu"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import styles from "@/pages/chat/css/MessageContextMenu.module.scss"; interface MessageContextMenuProps { @@ -35,7 +35,7 @@ export function MessageContextMenu({ isOpen, onOpenChange }: MessageContextMenuProps) { - const { user } = useAppState(); + const { user } = useUserStore(); // Internal state for closing animation const [isClosing, setIsClosing] = useState(false); const [reactionBarPosition, setReactionBarPosition] = useState({ x: 0, y: 0 }); diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index 7d00e6b..edb356b 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -1,6 +1,9 @@ import { useState, useEffect, useRef, useMemo, type ReactNode } from "react"; import { motion, AnimatePresence } from "motion/react"; -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; +import { useUserStore } from "@/state/user"; +import { usePresenceStore } from "@/state/presence"; +import { useProfileStore } from "@/state/profile"; import { MessagePanel, type MessagePanelState } from "./panels/MessagePanel"; import { ChatMessages } from "./ChatMessages"; import { ChatInputWrapper } from "./ChatInputWrapper"; @@ -23,19 +26,20 @@ interface MessagePanelRendererProps { } function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { - const { chat, user } = useAppState(); + const { typingUsers, dmTypingUsers } = usePresenceStore(); + const { user } = useUserStore(); const otherTypingUsers = useMemo(() => { return Array - .from(chat.typingUsers.entries()) + .from(typingUsers.entries()) .filter(([userId, username]) => userId !== user.currentUser?.id && username) .map(([, username]) => username!); - }, [chat.typingUsers, user.currentUser?.id]); + }, [typingUsers, user.currentUser?.id]); let content: ReactNode; if (panel instanceof DMPanel) { const recipientId = panel.getRecipientId()!; - const isTyping = chat.dmTypingUsers.get(recipientId); + const isTyping = dmTypingUsers.get(recipientId); content = isTyping ? : ; } else if (panel instanceof PublicChatPanel && otherTypingUsers.length > 0) { @@ -48,11 +52,14 @@ function ChatHeaderText({ panel }: { panel: MessagePanel | null }) { } export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { - const { applyPendingPanel, chat, setProfileDialog } = useAppState(); + const { applyPendingPanel, isSwitching, pendingPanel, activePanel, setIsSwitching } = useChatStore(); + const { setProfileDialog } = useProfileStore(); const messagePanelRef = useRef(null); const [panelState, setPanelState] = useState(null); const messagesEndRef = useRef(null); const previousMessageCountRef = useRef(0); + const messagesContainerRef = useRef(null); + const isLoadingMoreRef = useRef(false); const [replyTo, setReplyTo] = useState(null); const [replyToVisible, setReplyToVisible] = useState(Boolean(replyTo)); const [editMessage, setEditMessage] = useState(null); @@ -87,6 +94,50 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } }, [editMessage]); + // Handle scroll detection for infinite loading + useEffect(() => { + if (!panel || !panelState) return; + + const messagesContainer = document.getElementById("chat-messages"); + if (!messagesContainer) return; + + messagesContainerRef.current = messagesContainer; + + const handleScroll = async () => { + if (!panel || !panelState || isLoadingMoreRef.current) return; + + const container = messagesContainerRef.current; + if (!container) return; + + // Check if scrolled to top (within 100px threshold) + if (container.scrollTop <= 100 && panelState.hasMoreMessages && !panelState.isLoadingMore) { + isLoadingMoreRef.current = true; + const previousScrollHeight = container.scrollHeight; + + try { + await panel.loadMoreMessages(); + + // Preserve scroll position after loading + requestAnimationFrame(() => { + if (container) { + const newScrollHeight = container.scrollHeight; + container.scrollTop = newScrollHeight - previousScrollHeight; + } + isLoadingMoreRef.current = false; + }); + } catch (error) { + console.error("Error loading more messages:", error); + isLoadingMoreRef.current = false; + } + } + }; + + messagesContainer.addEventListener("scroll", handleScroll); + return () => { + messagesContainer.removeEventListener("scroll", handleScroll); + }; + }, [panel, panelState]); + // Handle panel state changes useEffect(() => { if (panel) { @@ -121,30 +172,30 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Handle chat switching animation useEffect(() => { - if (chat.isSwitching && chat.pendingPanel) { + if (isSwitching && pendingPanel) { // Apply pending panel when animation starts applyPendingPanel(); // End switching state after a brief delay to allow animation setTimeout(() => { - chat.setIsSwitching(false); + setIsSwitching(false); }, 200); } - }, [chat.isSwitching, chat.pendingPanel, applyPendingPanel]); + }, [isSwitching, pendingPanel, applyPendingPanel, setIsSwitching]); // Load messages when panel changes and animation is not running useEffect(() => { - if (!chat.activePanel || chat.isSwitching) return; + if (!activePanel || isSwitching) return; - const panelState = chat.activePanel.getState(); + const panelState = activePanel.getState(); if (panelState.messages.length === 0 && !panelState.isLoading) { - chat.activePanel.loadMessages(); + activePanel.loadMessages(); } - }, [chat.activePanel, chat.isSwitching]); + }, [activePanel, isSwitching]); // Scroll to bottom only when new messages are added useEffect(() => { - if (!panelState || chat.isSwitching) return; + if (!panelState || isSwitching) return; const currentMessageCount = panelState.messages.length; const previousMessageCount = previousMessageCountRef.current; @@ -168,7 +219,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { // Update the previous message count previousMessageCountRef.current = currentMessageCount; - }, [panelState?.messages, panelState?.isLoading, chat.isSwitching]); + }, [panelState?.messages, panelState?.isLoading, isSwitching]); function handleCallClick() { if (panel && panelState && panel.isDm()) { @@ -195,7 +246,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } } - const panelKey = chat.activePanel?.getState().title || "empty"; + const panelKey = activePanel?.getState().title || "empty"; return (
@@ -275,31 +326,43 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) {
) : panelState && panel ? ( - { - if (editMessage || editVisible) { - setPendingAction({ type: "reply", message: message }); - setEditVisible(false); // onCloseEdit will apply pending - } else { - setReplyTo(message); - } - }} - onEditSelect={(message) => { - if (replyTo || replyToVisible) { - setPendingAction({ type: "edit", message: message }); - setReplyToVisible(false); // onCloseReply will apply pending - } else { - setEditMessage(message); - } - }} - onDelete={(id) => panel.handleDeleteMessage(id)} - onRetryMessage={(id) => panel.retryMessage(id)} - > -
- + <> + {panelState.isLoadingMore && ( +
+ Загрузка... +
+ )} + { + if (editMessage || editVisible) { + setPendingAction({ type: "reply", message: message }); + setEditVisible(false); // onCloseEdit will apply pending + } else { + setReplyTo(message); + } + }} + onEditSelect={(message) => { + if (replyTo || replyToVisible) { + setPendingAction({ type: "edit", message: message }); + setReplyToVisible(false); // onCloseReply will apply pending + } else { + setEditMessage(message); + } + }} + onDelete={(id) => panel.handleDeleteMessage(id)} + onRetryMessage={(id) => panel.retryMessage(id)} + > +
+ + ) : (
+ return } \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx index 001bf3c..7bd404f 100644 --- a/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx +++ b/frontend/src/pages/chat/ui/right/calls/CallWindow.tsx @@ -1,5 +1,6 @@ import { useState, useEffect } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; +import { useUserStore } from "@/state/user"; import useCall from "@/pages/chat/hooks/useCall"; import defaultAvatar from "@/images/default-avatar.png"; import { createPortal } from "react-dom"; @@ -9,8 +10,8 @@ import { motion, AnimatePresence } from "motion/react"; import styles from "@/pages/chat/css/callWindow.module.scss"; export function CallWindow() { - const { chat, toggleCallMinimize, user } = useAppState(); - const { call } = chat; + const { call, toggleCallMinimized } = useCallStore(); + const { user } = useUserStore(); const { acceptCall, rejectCall, @@ -158,7 +159,7 @@ export function CallWindow() {
diff --git a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx index 16124bf..376a34d 100644 --- a/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx +++ b/frontend/src/pages/chat/ui/right/calls/MinimizedCallBar.tsx @@ -1,11 +1,10 @@ -import { useAppState } from "@/pages/chat/state"; +import { useCallStore } from "@/state/call"; import useCall from "@/pages/chat/hooks/useCall"; import defaultAvatar from "@/images/default-avatar.png"; import { MaterialIconButton } from "@/utils/material"; export function MinimizedCallBar() { - const { chat, toggleCallMinimize } = useAppState(); - const { call } = chat; + const { call, toggleCallMinimized } = useCallStore(); const { endCall, toggleMute } = useCall(); function getGradientClass() { @@ -39,7 +38,7 @@ export function MinimizedCallBar() { } return ( -
+
Avatar
diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 6956d1c..d940d78 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -1,15 +1,7 @@ import { MessagePanel } from "./MessagePanel"; -import { - fetchDMHistory, - decryptDm, - sendDMViaWebSocket, - sendDmWithFiles, - editDmEnvelope, - deleteDmEnvelope -} from "@/core/api/dmApi"; -import { fetchUserProfileById } from "@/core/api/profileApi"; +import api from "@/core/api"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; import { onlineStatusManager } from "@/core/onlineStatusManager"; import { typingManager } from "@/core/typingManager"; @@ -63,7 +55,7 @@ export class DMPanel extends MessagePanel { } private async parseTextPayload(env: DmEnvelope, decryptedMessages: Message[]) { - const plaintext = await decryptDm(env, this.dmData!.publicKey); + const plaintext = await api.chats.dm.decrypt(env, this.dmData!.publicKey); const username = formatDMUsername( env.senderId, env.recipientId, @@ -111,7 +103,8 @@ export class DMPanel extends MessagePanel { this.setLoading(true); try { - const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50); + const limit = this.calculateMessageLimit(); + const { messages, has_more } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, limit); const decryptedMessages: Message[] = []; let maxIncomingId = 0; @@ -130,6 +123,7 @@ export class DMPanel extends MessagePanel { this.clearMessages(); decryptedMessages.forEach(msg => this.addMessage(msg)); + this.setHasMoreMessages(has_more); // Update last read ID if (maxIncomingId > 0) { @@ -143,37 +137,77 @@ export class DMPanel extends MessagePanel { } } + async loadMoreMessages(): Promise { + if (!this.currentUser.authToken || !this.dmData || !this.state.hasMoreMessages || this.state.isLoadingMore) return; + + const messages = this.getMessages(); + if (messages.length === 0) return; + + const oldestMessage = messages[0]; + const oldestEnvelope = oldestMessage.runtimeData?.dmEnvelope; + if (!oldestEnvelope) return; + + this.setLoadingMore(true); + try { + const limit = this.calculateMessageLimit(); + const { messages: newEnvelopes, has_more } = await api.chats.dm.fetchMessages( + this.dmData.userId, + this.currentUser.authToken, + limit, + oldestEnvelope.id + ); + + if (newEnvelopes && newEnvelopes.length > 0) { + const decryptedMessages: Message[] = []; + for (const env of newEnvelopes) { + try { + const dmMsg = await this.parseTextPayload(env, decryptedMessages); + decryptedMessages.push(dmMsg); + } catch (error) { + console.error("Error decrypting message:", error); + } + } + + // Prepend older messages (they come in reverse chronological order) + this.updateState({ + messages: [...decryptedMessages.reverse(), ...messages] + }); + } + this.setHasMoreMessages(has_more); + } catch (error) { + console.error("Failed to load more DM messages:", error); + } finally { + this.setLoadingMore(false); + } + } + protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !this.dmData || !content.trim()) return; - try { - const payload: DmEncryptedJSON = { - type: "text", - data: { - content: content.trim(), - reply_to_id: replyToId ?? undefined - } + const payload: DmEncryptedJSON = { + type: "text", + data: { + content: content.trim(), + reply_to_id: replyToId ?? undefined } - const json = JSON.stringify(payload); + } + const json = JSON.stringify(payload); - if (files.length === 0) { - await sendDMViaWebSocket( - this.dmData.userId, - this.dmData.publicKey, - json, - this.currentUser.authToken - ); - } else { - await sendDmWithFiles( - this.dmData.userId, - this.dmData.publicKey, - json, - files, - this.currentUser.authToken - ); - } - } catch (error) { - console.error("Failed to send DM:", error); + if (files.length === 0) { + await api.chats.dm.send( + this.dmData.userId, + this.dmData.publicKey, + json, + this.currentUser.authToken + ); + } else { + await api.chats.dm.sendWithFiles( + this.dmData.userId, + this.dmData.publicKey, + json, + files, + this.currentUser.authToken + ); } } @@ -228,7 +262,7 @@ export class DMPanel extends MessagePanel { const { id, iv, ciphertext, salt, iv2, wrappedMk } = response.data; try { // Decrypt new content in-place - const plaintext = await decryptDm( + const plaintext = await api.chats.dm.decrypt( { id, senderId: 0, @@ -330,7 +364,7 @@ export class DMPanel extends MessagePanel { this.deleteMessageImmediately(messageId); // Fire and forget server deletion; UI already updated - await deleteDmEnvelope(messageId, this.dmData.userId, this.currentUser.authToken); + await api.chats.dm.deleteMessage(messageId, this.dmData.userId, this.currentUser.authToken); } async handleEditMessage(messageId: number, content: string): Promise { @@ -345,7 +379,7 @@ export class DMPanel extends MessagePanel { reply_to_id: msg?.reply_to?.id ?? undefined } }; - editDmEnvelope(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { + api.chats.dm.edit(messageId, this.dmData.publicKey, JSON.stringify(payload), this.currentUser.authToken).catch((e) => { console.error("Failed to edit DM:", e); }); } @@ -354,7 +388,7 @@ export class DMPanel extends MessagePanel { if (!this.dmData || !this.currentUser.authToken) return null; try { - const userProfile = await fetchUserProfileById(this.currentUser.authToken, this.dmData.userId); + const userProfile = await api.user.profile.fetchById(this.currentUser.authToken, this.dmData.userId); if (!userProfile) return null; return { diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index 805ed8b..f8bdf94 100644 --- a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts @@ -1,5 +1,11 @@ import type { Message, WebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; +import { alert } from "@/core/components/AlertDialog"; + +interface HttpError extends Error { + status?: number; + detail?: string; +} export interface MessagePanelState { id: string; @@ -9,6 +15,8 @@ export interface MessagePanelState { messages: Message[]; isLoading: boolean; isTyping: boolean; + hasMoreMessages: boolean; + isLoadingMore: boolean; } export interface MessagePanelCallbacks { @@ -35,7 +43,9 @@ export abstract class MessagePanel { online: false, messages: [], isLoading: false, - isTyping: false + isTyping: false, + hasMoreMessages: false, + isLoadingMore: false }; this.currentUser = currentUser; } @@ -46,7 +56,7 @@ export abstract class MessagePanel { abstract loadMessages(): Promise; protected abstract sendMessage(content: string, replyToId?: number, files?: File[]): Promise; abstract isDm(): boolean; - abstract handleWebSocketMessage(response: WebSocketMessage): Promise; + abstract handleWebSocketMessage(response: WebSocketMessage): Promise; abstract getProfile(): Promise; // Common methods @@ -87,7 +97,7 @@ export abstract class MessagePanel { }); } - protected updateMessageReactions(messageId: number, reactions: any[]): void { + protected updateMessageReactions(messageId: number, reactions: Message["reactions"]): void { this.updateState({ messages: this.state.messages.map(msg => msg.id === messageId ? { ...msg, reactions } : msg @@ -107,6 +117,27 @@ export abstract class MessagePanel { this.updateState({ isTyping: typing }); } + protected setLoadingMore(loading: boolean): void { + this.updateState({ isLoadingMore: loading }); + } + + protected setHasMoreMessages(hasMore: boolean): void { + this.updateState({ hasMoreMessages: hasMore }); + } + + /** + * Calculate message limit based on viewport height (5x screen height) + */ + protected calculateMessageLimit(): number { + const viewportHeight = window.innerHeight; + return Math.ceil((viewportHeight * 5) / 100); + } + + /** + * Load more messages (to be implemented by subclasses) + */ + abstract loadMoreMessages(): Promise; + // Getters getState(): MessagePanelState { return { ...this.state }; @@ -307,7 +338,30 @@ export abstract class MessagePanel { // Message sent successfully - will be updated when WebSocket confirms } catch (error) { console.error("Failed to send message:", error); - this.handleMessageFailed(tempId); + // Remove the temporary message from display + this.updateState({ + messages: this.state.messages.filter(msg => + msg.runtimeData?.sendingState?.tempId !== tempId + ) + }); + this.pendingMessages.delete(tempId); + clearTimeout(timeoutId); + + // Check if error has HTTP status code + const httpError = error as HttpError; + const httpStatus = httpError.status; + const errorMessage = error instanceof Error ? error.message : String(error); + + console.log("Error details:", { httpStatus, errorMessage, error }); + + // Check for profanity error: HTTP 422 status (Unprocessable Entity) + // Also check error message as fallback for WebSocket errors + if (httpStatus === 422 || errorMessage.includes("inappropriate content")) { + console.log("Showing profanity error dialog"); + void alert("Your message contains inappropriate content and cannot be sent."); + } else { + console.log("Error does not match profanity condition:", { httpStatus, errorMessage }); + } } } @@ -316,11 +370,6 @@ export abstract class MessagePanel { this.updateMessageToFailed(tempId); } - // Handle message send failure - private handleMessageFailed(tempId: string): void { - this.updateMessageToFailed(tempId); - } - // Helper method to update message to failed state private updateMessageToFailed(tempId: string): void { const pending = this.pendingMessages.get(tempId); diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index d8c0e7f..8a6a541 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -1,9 +1,8 @@ import { MessagePanel } from "./MessagePanel"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; import { request } from "@/core/websocket"; -import type { ChatWebSocketMessage, Message, SendMessageRequest, ReactionUpdateWebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; +import type { UserState, ProfileDialogData } from "@/state/types"; +import api from "@/core/api"; export class PublicChatPanel extends MessagePanel { private messagesLoaded: boolean = false; @@ -42,19 +41,15 @@ export class PublicChatPanel extends MessagePanel { this.setLoading(true); try { - const response = await fetch(`${API_BASE_URL}/get_messages`, { - headers: getAuthHeaders(this.currentUser.authToken) - }); - - if (response.ok) { - const data = await response.json(); - if (data.messages && data.messages.length > 0) { - this.clearMessages(); - data.messages.forEach((msg: Message) => { - this.addMessage(msg); - }); - } + const limit = this.calculateMessageLimit(); + const { messages, has_more } = await api.chats.general.fetchMessages(this.currentUser.authToken, limit); + if (messages && messages.length > 0) { + this.clearMessages(); + messages.forEach((msg: Message) => { + this.addMessage(msg); + }); } + this.setHasMoreMessages(has_more); this.messagesLoaded = true; } catch (error) { console.error("Error loading public chat messages:", error); @@ -63,43 +58,42 @@ export class PublicChatPanel extends MessagePanel { } } + async loadMoreMessages(): Promise { + if (!this.currentUser.authToken || !this.state.hasMoreMessages || this.state.isLoadingMore) return; + + const messages = this.getMessages(); + if (messages.length === 0) return; + + const oldestMessage = messages[0]; + this.setLoadingMore(true); + try { + const limit = this.calculateMessageLimit(); + const { messages: newMessages, has_more } = await api.chats.general.fetchMessages( + this.currentUser.authToken, + limit, + oldestMessage.id + ); + if (newMessages && newMessages.length > 0) { + // Prepend older messages (they come in reverse chronological order) + this.updateState({ + messages: [...newMessages.reverse(), ...messages] + }); + } + this.setHasMoreMessages(has_more); + } catch (error) { + console.error("Error loading more public chat messages:", error); + } finally { + this.setLoadingMore(false); + } + } + protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !content.trim()) return; - try { - if (files.length === 0) { - const response = await request({ - data: { - content: content.trim(), - reply_to_id: replyToId ?? null - }, - credentials: { - scheme: "Bearer", - credentials: this.currentUser.authToken - }, - type: "sendMessage" - } satisfies SendMessageRequest); - if (response.error) { - console.error("Error sending message:", response.error); - } - } else { - const form = new FormData(); - form.append("payload", JSON.stringify({ - content: content.trim(), - reply_to_id: replyToId ?? null - } satisfies SendMessageRequest["data"])); - for (const f of files) form.append("files", f, f.name); - const res = await fetch(`${API_BASE_URL}/send_message`, { - method: "POST", - headers: getAuthHeaders(this.currentUser.authToken, false), - body: form - }); - if (!res.ok) { - console.error("Error sending message with files", await res.text()); - } - } - } catch (error) { - console.error("Error sending message:", error); + if (files.length === 0) { + await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken); + } else { + await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); } } diff --git a/frontend/src/pages/home/HomePage.tsx b/frontend/src/pages/home/HomePage.tsx index 43ca68e..3aafdb6 100644 --- a/frontend/src/pages/home/HomePage.tsx +++ b/frontend/src/pages/home/HomePage.tsx @@ -1,5 +1,5 @@ import { useNavigate } from "react-router-dom"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import styles from "./home.module.scss"; import useDownloadAppScreen from "@/core/hooks/useDownloadAppScreen"; import { MaterialButton, MaterialIcon } from "@/utils/material"; @@ -18,7 +18,7 @@ function SupportLink({ children }: { children: React.ReactNode }) { export default function HomePage() { const navigate = useNavigate(); - const { user } = useAppState(); + const { user } = useUserStore(); const { isMobile } = useDownloadAppScreen(); const isLoggedIn = user.authToken && user.currentUser; diff --git a/frontend/src/pages/home/home.module.scss b/frontend/src/pages/home/home.module.scss index 40327db..7284c6a 100644 --- a/frontend/src/pages/home/home.module.scss +++ b/frontend/src/pages/home/home.module.scss @@ -58,7 +58,7 @@ font-weight: 700; margin: 0; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); @@ -99,7 +99,7 @@ line-height: 1.1; margin-bottom: 1.5rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary, $color-dark-secondary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 30px rgba($color-dark-primary, 0.5); @@ -246,7 +246,7 @@ font-weight: 700; margin-bottom: 3rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); @@ -348,7 +348,7 @@ font-weight: 700; margin-bottom: 1.5rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); @@ -387,7 +387,7 @@ font-weight: 700; margin-bottom: 1.5rem; background: linear-gradient(45deg, $color-dark-primary, $color-dark-tertiary); - -webkit-background-clip: text; + background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-shadow: 0 0 20px rgba($color-dark-primary, 0.5); diff --git a/frontend/src/pages/not-found/not-found.module.scss b/frontend/src/pages/not-found/not-found.module.scss index 82cefbb..69a33c9 100644 --- a/frontend/src/pages/not-found/not-found.module.scss +++ b/frontend/src/pages/not-found/not-found.module.scss @@ -3,7 +3,7 @@ align-items: center; justify-content: center; min-height: 100vh; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + background: linear-gradient(135deg, #9333EA 0%, #6366F1 100%); padding: 2rem; } @@ -42,7 +42,7 @@ .errorCode { font-size: 6rem; font-weight: 900; - color: #667eea; + color: #9333EA; line-height: 1; margin-bottom: 1rem; text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1); @@ -58,7 +58,7 @@ display: flex; align-items: center; justify-content: center; - color: #667eea; + color: #9333EA; opacity: 0.7; } diff --git a/frontend/src/state/call.ts b/frontend/src/state/call.ts new file mode 100644 index 0000000..688285d --- /dev/null +++ b/frontend/src/state/call.ts @@ -0,0 +1,123 @@ +import { create } from "zustand"; +import type { CallStatus, CallState } from "./types"; + +interface CallStore { + call: CallState; + startCall: (userId: number, username: string) => void; + endCall: () => void; + setCallStatus: (status: CallStatus) => void; + toggleMute: () => void; + toggleCallMinimize: () => void; + receiveCall: (userId: number, username: string) => void; + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => void; + setCallSessionKeyHash: (sessionKeyHash: string) => void; + toggleVideo: () => void; + toggleScreenShare: () => void; + setRemoteVideoEnabled: (enabled: boolean) => void; + setRemoteScreenSharing: (enabled: boolean) => void; + toggleCallMinimized: () => void; +} + +const initialCallState: CallState = { + isActive: false, + status: "ended", + startTime: null, + isMuted: false, + remoteUserId: null, + remoteUsername: null, + isInitiator: false, + isMinimized: false, + sessionKeyHash: null, + encryptionEmojis: [], + isVideoEnabled: false, + isRemoteVideoEnabled: false, + isSharingScreen: false, + isRemoteScreenSharing: false +}; + +export const useCallStore = create((set) => ({ + call: initialCallState, + startCall: (userId: number, username: string) => set({ + call: { + ...initialCallState, + isActive: true, + status: "calling", + remoteUserId: userId, + remoteUsername: username, + isInitiator: true + } + }), + endCall: () => set({ call: initialCallState }), + setCallStatus: (status: CallStatus) => set((state) => ({ + call: { + ...state.call, + status, + startTime: status === "active" && !state.call.startTime ? Date.now() : state.call.startTime + } + })), + toggleMute: () => set((state) => ({ + call: { + ...state.call, + isMuted: !state.call.isMuted + } + })), + toggleCallMinimize: () => set((state) => ({ + call: { + ...state.call, + isMinimized: !state.call.isMinimized + } + })), + receiveCall: (userId: number, username: string) => set({ + call: { + ...initialCallState, + isActive: true, + status: "calling", + remoteUserId: userId, + remoteUsername: username, + isInitiator: false + } + }), + setCallEncryption: (sessionKeyHash: string, encryptionEmojis: string[]) => set((state) => ({ + call: { + ...state.call, + sessionKeyHash, + encryptionEmojis + } + })), + setCallSessionKeyHash: (sessionKeyHash: string) => set((state) => ({ + call: { + ...state.call, + sessionKeyHash + } + })), + toggleVideo: () => set((state) => ({ + call: { + ...state.call, + isVideoEnabled: !state.call.isVideoEnabled + } + })), + toggleScreenShare: () => set((state) => ({ + call: { + ...state.call, + isSharingScreen: !state.call.isSharingScreen + } + })), + setRemoteVideoEnabled: (enabled: boolean) => set((state) => ({ + call: { + ...state.call, + isRemoteVideoEnabled: enabled + } + })), + setRemoteScreenSharing: (enabled: boolean) => set((state) => ({ + call: { + ...state.call, + isRemoteScreenSharing: enabled + } + })), + toggleCallMinimized: () => set((state) => ({ + call: { + ...state.call, + isMinimized: !state.call.isMinimized + } + })) +})); diff --git a/frontend/src/state/chat.ts b/frontend/src/state/chat.ts new file mode 100644 index 0000000..74e5218 --- /dev/null +++ b/frontend/src/state/chat.ts @@ -0,0 +1,150 @@ +import { create } from "zustand"; +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; +import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; +import type { DMPanelData } from "@/pages/chat/ui/right/panels/DMPanel"; +import type { ChatTabs, ActiveDM } from "./types"; +import { useUserStore } from "./user"; + +interface ChatStore { + messages: Message[]; + currentChat: string; + activeTab: ChatTabs; + dmUsers: User[]; + activeDm: ActiveDM | null; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; + addMessage: (message: Message) => void; + updateMessage: (messageId: number, updatedMessage: Partial) => void; + removeMessage: (messageId: number) => void; + setCurrentChat: (chat: string) => void; + setActiveTab: (tab: ChatTabs) => void; + setDmUsers: (users: User[]) => void; + setActiveDm: (dm: ActiveDM | null) => void; + clearMessages: () => void; + setActivePanel: (panel: MessagePanel | null) => void; + setPendingPanel: (panel: MessagePanel | null) => void; + applyPendingPanel: () => void; + switchToPublicChat: (chatName: string) => Promise; + switchToDM: (dmData: DMPanelData) => Promise; +} + +export const useChatStore = create((set, get) => ({ + messages: [], + currentChat: "Общий чат", + activeTab: "chats", + dmUsers: [], + activeDm: null, + isSwitching: false, + setIsSwitching: (value: boolean) => set({ isSwitching: value }), + activePanel: null, + publicChatPanel: null, + dmPanel: null, + pendingPanel: null, + addMessage: (message: Message) => set((state) => { + const messageExists = state.messages.some(msg => msg.id === message.id); + if (messageExists) { + return state; + } + return { + messages: [...state.messages, message] + }; + }), + updateMessage: (messageId: number, updatedMessage: Partial) => set((state) => ({ + messages: state.messages.map(msg => + msg.id === messageId ? { ...msg, ...updatedMessage } : msg + ) + })), + removeMessage: (messageId: number) => set((state) => ({ + messages: state.messages.filter(msg => msg.id !== messageId) + })), + clearMessages: () => set({ messages: [] }), + setCurrentChat: (chat: string) => set({ currentChat: chat }), + setActiveTab: (tab: ChatTabs) => set({ activeTab: tab }), + setDmUsers: (users: User[]) => set({ dmUsers: users }), + setActiveDm: (dm: ActiveDM | null) => set({ activeDm: dm }), + setActivePanel: (panel: MessagePanel | null) => { + const state = get(); + if (state.activePanel && state.activePanel !== panel) { + state.activePanel.deactivate(); + } + return set({ activePanel: panel }); + }, + setPendingPanel: (panel: MessagePanel | null) => set({ pendingPanel: panel }), + applyPendingPanel: () => { + const state = get(); + if (state.activePanel) { + state.activePanel.deactivate(); + } + return set((state) => ({ + activePanel: state.pendingPanel || state.activePanel, + publicChatPanel: (state.pendingPanel instanceof PublicChatPanel) + ? (state.pendingPanel as PublicChatPanel) + : state.publicChatPanel, + dmPanel: (state.pendingPanel instanceof DMPanel) + ? (state.pendingPanel as DMPanel) + : state.dmPanel, + currentChat: state.pendingPanel ? state.pendingPanel.getState().title || state.currentChat : state.currentChat, + pendingPanel: null + })); + }, + switchToPublicChat: async (chatName: string) => { + const { user } = useUserStore.getState(); + const state = get(); + + if (!user.authToken) return; + + state.setIsSwitching(true); + + let publicChatPanel = state.publicChatPanel; + if (!publicChatPanel) { + publicChatPanel = new PublicChatPanel(chatName, user); + } else { + publicChatPanel.setChatName(chatName); + publicChatPanel.setAuthToken(user.authToken); + publicChatPanel.clearMessages(); + } + + await publicChatPanel.activate(); + + set({ + pendingPanel: publicChatPanel, + activeTab: "chats" + }); + }, + switchToDM: async (dmData: DMPanelData) => { + const { user } = useUserStore.getState(); + const state = get(); + + if (!user.authToken) return; + + state.setIsSwitching(true); + + let dmPanel = state.dmPanel; + if (!dmPanel) { + dmPanel = new DMPanel(user); + } else { + dmPanel.setAuthToken(user.authToken); + dmPanel.clearMessages(); + } + + dmPanel.setDMData(dmData); + + await dmPanel.activate(); + + set({ + pendingPanel: dmPanel, + activeDm: { + userId: dmData.userId, + username: dmData.username, + publicKey: dmData.publicKey + }, + activeTab: "chats" + }); + } +})); diff --git a/frontend/src/state/presence.ts b/frontend/src/state/presence.ts new file mode 100644 index 0000000..9220f00 --- /dev/null +++ b/frontend/src/state/presence.ts @@ -0,0 +1,41 @@ +import { create } from "zustand"; + +interface PresenceStore { + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => void; + addTypingUser: (userId: number, username: string) => void; + removeTypingUser: (userId: number) => void; + setDmTypingUser: (userId: number, isTyping: boolean) => void; +} + +export const usePresenceStore = create((set) => ({ + onlineStatuses: new Map(), + typingUsers: new Map(), + dmTypingUsers: new Map(), + updateOnlineStatus: (userId: number, online: boolean, lastSeen: string) => set((state) => ({ + onlineStatuses: new Map(state.onlineStatuses).set(userId, { online, lastSeen }) + })), + addTypingUser: (userId: number, username: string) => set((state) => ({ + typingUsers: new Map(state.typingUsers).set(userId, username) + })), + removeTypingUser: (userId: number) => set((state) => { + const newTypingUsers = new Map(state.typingUsers); + newTypingUsers.delete(userId); + return { + typingUsers: newTypingUsers + }; + }), + setDmTypingUser: (userId: number, isTyping: boolean) => set((state) => { + const newDmTypingUsers = new Map(state.dmTypingUsers); + if (isTyping) { + newDmTypingUsers.set(userId, true); + } else { + newDmTypingUsers.delete(userId); + } + return { + dmTypingUsers: newDmTypingUsers + }; + }) +})); diff --git a/frontend/src/state/profile.ts b/frontend/src/state/profile.ts new file mode 100644 index 0000000..35a6314 --- /dev/null +++ b/frontend/src/state/profile.ts @@ -0,0 +1,14 @@ +import { create } from "zustand"; +import type { ProfileDialogData } from "./types"; + +interface ProfileStore { + profileDialog: ProfileDialogData | null; + setProfileDialog: (data: ProfileDialogData | null) => void; + closeProfileDialog: () => void; +} + +export const useProfileStore = create((set) => ({ + profileDialog: null, + setProfileDialog: (data: ProfileDialogData | null) => set({ profileDialog: data }), + closeProfileDialog: () => set({ profileDialog: null }) +})); diff --git a/frontend/src/state/types.ts b/frontend/src/state/types.ts new file mode 100644 index 0000000..eb251f2 --- /dev/null +++ b/frontend/src/state/types.ts @@ -0,0 +1,73 @@ +import type { Message, User } from "@/core/types"; +import { MessagePanel } from "@/pages/chat/ui/right/panels/MessagePanel"; +import { PublicChatPanel } from "@/pages/chat/ui/right/panels/PublicChatPanel"; +import { DMPanel } from "@/pages/chat/ui/right/panels/DMPanel"; + +export type ChatTabs = "chats" | "channels" | "contacts"; + +export type CallStatus = "calling" | "connecting" | "active" | "ended"; + +export interface ProfileDialogData { + userId?: number; + username?: string; + display_name?: string; + profilePicture?: string; + bio?: string; + memberSince?: string; + online?: boolean; + isOwnProfile: boolean; + verified?: boolean; + suspended?: boolean; + suspension_reason?: string | null; + deleted?: boolean; +} + +export interface ActiveDM { + userId: number; + username: string; + publicKey: string | null; +} + +export interface CallState { + isActive: boolean; + status: CallStatus; + startTime: number | null; + isMuted: boolean; + remoteUserId: number | null; + remoteUsername: string | null; + isInitiator: boolean; + isMinimized: boolean; + sessionKeyHash: string | null; + encryptionEmojis: string[]; + isVideoEnabled: boolean; + isRemoteVideoEnabled: boolean; + isSharingScreen: boolean; + isRemoteScreenSharing: boolean; +} + +export interface ChatState { + messages: Message[]; + currentChat: string; + activeTab: ChatTabs; + dmUsers: User[]; + activeDm: ActiveDM | null; + isSwitching: boolean; + setIsSwitching: (value: boolean) => void; + activePanel: MessagePanel | null; + publicChatPanel: PublicChatPanel | null; + dmPanel: DMPanel | null; + pendingPanel?: MessagePanel | null; + call: CallState; + profileDialog: ProfileDialogData | null; + onlineStatuses: Map; + typingUsers: Map; // userId -> username + dmTypingUsers: Map; +} + +export interface UserState { + currentUser: User | null; + authToken: string | null; + isSuspended: boolean; + suspensionReason: string | null; +} + diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts new file mode 100644 index 0000000..8c8e036 --- /dev/null +++ b/frontend/src/state/user.ts @@ -0,0 +1,141 @@ +import { create } from "zustand"; +import type { User } from "@/core/types"; +import api from "@/core/api"; +import { API_BASE_URL } from "@/core/config"; +import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; +import { isElectron } from "@/core/electron/electron"; +import { onlineStatusManager } from "@/core/onlineStatusManager"; +import { typingManager } from "@/core/typingManager"; +import type { UserState } from "./types"; + +interface UserStore { + user: UserState; + setUser: (token: string, user: User) => void; + logout: () => void; + restoreFromStorage: () => Promise; + setSuspended: (reason: string) => void; +} + +export const useUserStore = create((set) => ({ + user: { + currentUser: null, + authToken: null, + isSuspended: false, + suspensionReason: null + }, + setUser: (token: string, user: User) => { + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: user.suspended || false, + suspensionReason: user.suspension_reason || null + } + }); + + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + + try { + localStorage.setItem('authToken', token); + localStorage.setItem('currentUser', JSON.stringify(user)); + } catch (error) { + console.error('Failed to store credentials in localStorage:', error); + } + + // Ping will be sent automatically on WebSocket reconnect + // No need to send here to avoid duplicate pings + }, + logout: () => { + try { + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + } catch (error) { + console.error('Failed to clear localStorage:', error); + } + + onlineStatusManager.setAuthToken(null); + typingManager.setAuthToken(null); + onlineStatusManager.cleanup(); + typingManager.cleanup(); + + set({ + user: { + currentUser: null, + authToken: null, + isSuspended: false, + suspensionReason: null + } + }); + }, + restoreFromStorage: async () => { + try { + const token = localStorage.getItem('authToken'); + + if (token) { + const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { + headers: api.user.auth.getAuthHeaders(token, true) + }); + if (fullResponse.ok) { + const user: User = await fullResponse.json(); + api.user.auth.restoreKeys(); + + if (user.suspended) { + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: true, + suspensionReason: user.suspension_reason || null + } + }); + return; + } + + set({ + user: { + currentUser: user, + authToken: token, + isSuspended: false, + suspensionReason: null + } + }); + + onlineStatusManager.setAuthToken(token); + typingManager.setAuthToken(token); + + // Ping will be sent automatically on WebSocket reconnect + // No need to send here to avoid duplicate pings + + try { + if (isSupported()) { + const initialized = await initialize(); + if (initialized) { + await subscribe(token); + + if (isElectron) { + await startElectronReceiver(); + } + } + } + } catch (e) { + console.error("Notification setup failed (restored):", e); + } + } else { + throw new Error("Unable to authenticate"); + } + } + } catch (error) { + console.error('Failed to restore user from localStorage:', error); + localStorage.removeItem('authToken'); + localStorage.removeItem('currentUser'); + } + }, + setSuspended: (reason: string) => set((state) => ({ + user: { + ...state.user, + isSuspended: true, + suspensionReason: reason + } + })) +})); diff --git a/frontend/src/utils/crypto/fromchatInit.ts b/frontend/src/utils/crypto/fromchatInit.ts new file mode 100644 index 0000000..6e1e617 --- /dev/null +++ b/frontend/src/utils/crypto/fromchatInit.ts @@ -0,0 +1,26 @@ +import { FromChatProtocol } from "@fromchat/protocol"; +import { getCurrentKeys } from "@/core/api/user/auth"; + +let protocolInstance: FromChatProtocol | null = null; + +export function getFromChatProtocol(): FromChatProtocol | null { + return protocolInstance; +} + +export function initializeFromChatProtocol(privateKey: Uint8Array): FromChatProtocol { + protocolInstance = new FromChatProtocol(privateKey); + return protocolInstance; +} + +export function getOrInitProtocol(): FromChatProtocol { + if (protocolInstance) { + return protocolInstance; + } + + const keys = getCurrentKeys(); + if (!keys) { + throw new Error("Keys not initialized"); + } + + return initializeFromChatProtocol(keys.privateKey); +} diff --git a/frontend/src/utils/material.tsx b/frontend/src/utils/material.tsx index bd698fc..31203d8 100644 --- a/frontend/src/utils/material.tsx +++ b/frontend/src/utils/material.tsx @@ -40,7 +40,7 @@ import type { Badge } from 'mdui/components/badge'; import type { CircularProgress } from 'mdui/components/circular-progress'; import type { BottomAppBar } from 'mdui/components/bottom-app-bar'; -setColorScheme("#91cef4"); +setColorScheme("#9333EA"); type BasePropCustomization = Override, { ref?: Ref; diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 4189505..9efcafb 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -14,10 +14,11 @@ "noEmit": true, /* Path mapping */ - "baseUrl": ".", - "paths": { - "@/*": ["src/*"] - }, + "baseUrl": ".", + "paths": { + "@/*": ["src/*"], + "@fromchat/protocol": ["./packages/fromchat-protocol/src"] + }, /* Linting */ "strict": true, @@ -31,6 +32,6 @@ "jsx": "react-jsx", "jsxImportSource": "react" }, - "include": ["src", "electron.d.ts"], + "include": ["src", "electron.d.ts", "packages/fromchat-protocol/src"], "exclude": ["**/__*/**", "__*"] } \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index ab5318a..32018c9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -7,6 +7,7 @@ import path from "path"; import { visualizer } from 'rollup-plugin-visualizer'; import sassDts from 'vite-plugin-sass-dts'; import { optimizeCssModules } from './plugins/optimizeCssModules'; +import { optimizeSvg } from './plugins/optimizeSvg'; const currentDir = path.resolve(__dirname); const outDir = process.env.VITE_ELECTRON ? `${currentDir}/build/electron` : `${currentDir}/build/normal`; @@ -21,6 +22,7 @@ const plugins: PluginOption[] = [ enabledMode: ['development', 'production'] }), optimizeCssModules(), + optimizeSvg(), createHtmlPlugin({ minify: { collapseWhitespace: true, @@ -68,7 +70,8 @@ export default defineConfig({ plugins: plugins, resolve: { alias: { - "@": path.resolve(__dirname, "./src") + "@": path.resolve(__dirname, "./src"), + "@fromchat/protocol": path.resolve(__dirname, "./packages/fromchat-protocol/src/index.ts") } }, server: { @@ -87,6 +90,7 @@ export default defineConfig({ }, appType: "spa", optimizeDeps: { + exclude: ["@fromchat/protocol"], esbuildOptions: { target: "es2022" } diff --git a/package.json b/package.json index 19ffa01..646a7e2 100644 --- a/package.json +++ b/package.json @@ -27,9 +27,11 @@ "preview": "cd deployment && docker compose up --build --watch", "preview:clean": "cd deployment && docker compose down -v", "clean": "npm run backend:clean && npm run frontend:clean && npm run preview:clean", - "install": "npm run backend:dependencies && npm run generate:env", + "install": "npm run backend:dependencies && if [[ ! -f deployment/.env ]]; then npm run generate:env; fi && npm run install:pussh", + "install:pussh": "bash ./scripts/install:pussh.sh", "prepare": "husky", - "generate:env": "bash ./scripts/generate:env.sh" + "generate:env": "bash ./scripts/generate:env.sh", + "deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh" }, "files": [ "frontend/build/electron" @@ -44,17 +46,20 @@ "@electron-forge/plugin-auto-unpack-natives": "^7.9.0", "@electron-forge/plugin-fuses": "^7.9.0", "@electron/fuses": "^1.0.0", + "@types/he": "^1.2.3", "@types/react": "^19.1.13", "@types/react-dom": "^19.1.9", "@vitejs/plugin-react": "^5.0.3", "autoprefixer": "^10.4.21", + "baseline-browser-mapping": "^2.9.11", "concurrently": "^9.2.1", "dotenv-cli": "^11.0.0", - "electron": "^38.1.2", + "electron": "^39.2.7", "husky": "^9.1.7", "postcss": "^8.5.6", "rollup-plugin-visualizer": "^6.0.4", "sass-embedded": "^1.93.0", + "svgo": "^4.0.0", "terser": "^5.44.0", "typescript": "~5.9.2", "vite": "^7.1.6", @@ -63,11 +68,16 @@ "vite-plugin-html": "^3.2.2", "vite-plugin-sass-dts": "^1.3.34" }, + "workspaces": [ + "frontend/packages/fromchat-protocol" + ], "dependencies": { - "dompurify": "^3.2.7", + "@fromchat/protocol": "workspace:*", "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", - "marked": "^16.3.0", + "he": "^1.2.0", + "idb": "^8.0.3", + "marked": "^17.0.1", "mdui": "^2.1.4", "motion": "^12.23.24", "react": "^19.1.1", diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..6f7d888 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,538 @@ +#!/bin/bash +set -e + +# Complete deployment script: build and push to server +# Usage: ./scripts/deploy.sh [server_user@server_host] [deployment_path] [platform] +# Example: ./scripts/deploy.sh user@example.com /home/user/fromchat linux/arm64 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +MAGENTA='\033[0;35m' +NC='\033[0m' # No Color +BOLD='\033[1m' + +# Helper functions +info() { echo -e "${BLUE}ℹ${NC} $1"; } +success() { echo -e "${GREEN}✓${NC} $1"; } +warning() { echo -e "${YELLOW}⚠${NC} $1"; } +error() { echo -e "${RED}✗${NC} $1"; } +step() { echo -e "${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; } +substep() { + if [ "$2" = "-n" ]; then + echo -n -e " ${GREEN}•${NC} $1" + else + echo -e " ${GREEN}•${NC} $1" + fi +} + +echo -e "${MAGENTA}${BOLD}🚀 Deployment${NC}\n" + +# Read password with asterisks +read_password() { + local password="" + local char + local old_stty + + # Save current terminal settings + old_stty=$(stty -g 2>/dev/null) + + # Disable echo + stty -echo 2>/dev/null + + # Read characters one by one + while IFS= read -rs -n 1 char; do + # Check for Enter key (empty means Enter was pressed) + if [ -z "$char" ]; then + break + fi + # Check for backspace/delete (ASCII 127) + if [ "$char" = $'\177' ] || [ "$char" = $'\b' ]; then + if [ ${#password} -gt 0 ]; then + password="${password%?}" + printf "\b \b" >&2 + fi + else + password+="$char" + printf "*" >&2 + fi + done + + # Restore terminal settings + stty "$old_stty" 2>/dev/null + echo "" >&2 + echo "$password" +} + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +DEPLOYMENT_DIR="$PROJECT_ROOT/deployment" +ENV_FILE="$DEPLOYMENT_DIR/.env" + +# Load .env file if it exists +if [ -f "$ENV_FILE" ]; then + # Export variables from .env file (ignore comments and empty lines) + set -a + while IFS= read -r line || [ -n "$line" ]; do + # Skip comments and empty lines + case "$line" in + \#*|'') continue ;; + *) + # Export the variable + export "$line" 2>/dev/null || true + ;; + esac + done < "$ENV_FILE" + set +a +fi + +# Read server from environment variable (from .env), command line argument, or fallback +SERVER="${1:-${DEPLOYMENT_SERVER:-}}" +REPO_NAME="FromChat" +DEPLOY_PATH="~/actions-runner/_work/$REPO_NAME/$REPO_NAME" +PLATFORM="linux/arm64" + +# Check if server is provided +if [ -z "$SERVER" ]; then + error "Server not specified. Usage: $0 [user@host] [deployment_path] [platform]" + echo " Or set DEPLOYMENT_SERVER in $ENV_FILE or as an environment variable" + echo "" + echo "Example:" + echo " $0 user@example.com /home/user/fromchat linux/arm64" + echo " Or add to $ENV_FILE: DEPLOYMENT_SERVER=user@example.com" + echo " Or: DEPLOYMENT_SERVER=user@example.com $0" + exit 1 +fi + +# ============================================================================ +# SSH AUTHENTICATION +# ============================================================================ + +step "Authentication" +SSH_KEY_FILE="$HOME/.ssh/id_rsa" +SSH_KEY_PUB_FILE="$SSH_KEY_FILE.pub" + +# Ensure ssh-agent is running +if [ -z "$SSH_AUTH_SOCK" ]; then + eval "$(ssh-agent -s)" > /dev/null 2>&1 +fi + +# Check if SSH key exists +if [ ! -f "$SSH_KEY_FILE" ]; then + error "SSH key not found at $SSH_KEY_FILE" + echo " Please generate an SSH key pair first:" + echo " ssh-keygen -t rsa -b 4096 -C 'your_email@example.com'" + exit 1 +fi + +# Add SSH key to agent if not already loaded +KEY_LOADED=false +if ssh-add -l > /dev/null 2>&1; then + # Check if this specific key is loaded by trying to match the public key + KEY_FINGERPRINT=$(ssh-keygen -lf "$SSH_KEY_FILE" 2>/dev/null | awk '{print $2}') + if [ -n "$KEY_FINGERPRINT" ] && ssh-add -l 2>/dev/null | grep -q "$KEY_FINGERPRINT"; then + KEY_LOADED=true + fi +fi + +if [ "$KEY_LOADED" = false ]; then + substep "Adding SSH key to agent..." + if ! ssh-add "$SSH_KEY_FILE" 2>/dev/null; then + error "Failed to add SSH key to agent. Check your key passphrase." + exit 1 + fi +fi + +# Check if SSH key authentication already works +if ssh -o BatchMode=yes -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" "echo 'SSH key works'" >/dev/null 2>&1; then + # SSH key already works, no need to copy + true +else + # Check if our public key is already on the server + KEY_CONTENT=$(cat "$SSH_KEY_PUB_FILE") + if ssh -o BatchMode=no -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SERVER" " + grep -q '$KEY_CONTENT' ~/.ssh/authorized_keys 2>/dev/null + " >/dev/null 2>&1; then + # Key exists but authentication failed - might be permissions issue + error "SSH key found on server but authentication failed. Check server SSH configuration." + exit 1 + else + # Key not on server, need to copy it + substep "SSH password: " -n + SSH_PASSWORD=$(read_password) + + if [ -z "$SSH_PASSWORD" ]; then + error "No SSH password provided" + exit 1 + fi + + substep "Copying SSH key to server..." + if command -v expect >/dev/null 2>&1; then + expect << EOF >/dev/null 2>&1 +spawn ssh-copy-id -o ConnectTimeout=10 -o StrictHostKeyChecking=no -i "$SSH_KEY_PUB_FILE" "$SERVER" +expect "password:" +send "$SSH_PASSWORD\r" +expect eof +EOF + if [ $? -eq 0 ]; then + true + else + error "Failed to copy SSH key to server" + exit 1 + fi + else + error "expect not available - cannot copy SSH key" + exit 1 + fi + fi +fi + +# ============================================================================ +# SUDO AUTHENTICATION +# ============================================================================ + +SUDO_PASSWORD="" +# If SSH password was provided, try using it for sudo first +if [ -n "$SSH_PASSWORD" ]; then + if echo "$SSH_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then + SUDO_PASSWORD="$SSH_PASSWORD" + export SUDO_PASSWORD + fi +fi + +# If we don't have a working sudo password yet, prompt for it +if [ -z "$SUDO_PASSWORD" ]; then + while true; do + substep "Sudo password: " -n + SUDO_PASSWORD=$(read_password) + + if [ -z "$SUDO_PASSWORD" ]; then + warning "No password provided - assuming passwordless sudo" + break + fi + + if echo "$SUDO_PASSWORD" | ssh "$SERVER" "sudo -S -v" > /dev/null 2>&1; then + export SUDO_PASSWORD + break + else + echo -n " " && error "Invalid password, please try again" + fi + done +fi + +# ============================================================================ +# BUILD PHASE +# ============================================================================ + +echo -e "\n${MAGENTA}${BOLD}🔨 Building Docker images${NC}\n" + +# Determine project name +if [ -n "$SERVER" ]; then + COMPOSE_DIR=$(ssh "$SERVER" "dirname $DEPLOY_PATH/deployment/docker-compose.yml" 2>/dev/null || echo "$DEPLOY_PATH/deployment") + PROJECT_NAME=$(ssh "$SERVER" "basename $COMPOSE_DIR" 2>/dev/null || echo "deployment") +else + PROJECT_NAME=$(basename "$DEPLOYMENT_DIR") +fi + +# Check if Docker daemon is running +check_docker_daemon() { + docker info > /dev/null 2>&1 +} + +# Start Docker Desktop +start_docker_desktop() { + substep "Starting Docker Desktop..." + if ! docker desktop start > /dev/null 2>&1; then + return 1 + fi + + # Wait for Docker to be ready (max 60 seconds) + substep "Waiting for Docker to start..." -n + local max_wait=60 + local waited=0 + while [ $waited -lt $max_wait ]; do + if check_docker_daemon; then + echo "" + return 0 + fi + sleep 2 + waited=$((waited + 2)) + echo -n "." + done + echo "" + return 1 +} + +# Check buildx +if ! docker buildx version > /dev/null 2>&1; then + error "Docker buildx not available. Install Docker Desktop." +fi + +# Check Docker daemon +if ! check_docker_daemon; then + warning "Docker daemon is not running" + if ! start_docker_desktop; then + error "Failed to start Docker Desktop. Please start it manually and try again." + fi +fi + +# Setup buildx builder +step "Setting up buildx builder" +BUILDER_NAME="fromchat-builder" +BUILDER_EXISTS=false + +if docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then + BUILDER_EXISTS=true + if ! docker buildx use "$BUILDER_NAME" > /dev/null 2>&1; then + substep "Recreating builder..." + docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true + BUILDER_EXISTS=false + elif ! docker buildx inspect "$BUILDER_NAME" > /dev/null 2>&1; then + substep "Recreating builder (inspection failed)..." + docker buildx rm "$BUILDER_NAME" > /dev/null 2>&1 || true + BUILDER_EXISTS=false + fi +fi + +if [ "$BUILDER_EXISTS" = false ]; then + substep "Creating builder with persistent cache..." + docker buildx create \ + --name "$BUILDER_NAME" \ + --driver docker-container \ + --driver-opt image=moby/buildkit:latest \ + --use \ + --bootstrap > /dev/null 2>&1 +fi + +docker buildx use "$BUILDER_NAME" > /dev/null 2>&1 + +# Detect services +step "Detecting services" +cd "$DEPLOYMENT_DIR" +SERVICES=$(docker compose -f docker-compose.yml config --services 2>/dev/null) + +if [ -z "$SERVICES" ]; then + error "No services found in docker-compose.yml" +fi + +BUILT_IMAGES=() + +for SERVICE in $SERVICES; do + HAS_BUILD=$(docker compose -f docker-compose.yml config 2>/dev/null | \ + grep -A 30 "^[[:space:]]*${SERVICE}:" | \ + grep -q "build:" && echo "yes" || echo "no") + + if [ "$HAS_BUILD" != "yes" ]; then + continue + fi + + IMAGE_TAG="${PROJECT_NAME}-${SERVICE}:latest" + + substep "Building ${CYAN}$SERVICE${NC} -> ${CYAN}$IMAGE_TAG${NC}..." + + BUILD_OUTPUT=$(docker compose -f docker-compose.yml config 2>/dev/null | \ + grep -A 15 "^[[:space:]]*${SERVICE}:" | \ + grep -A 10 "build:") + + DOCKERFILE_REL=$(echo "$BUILD_OUTPUT" | grep "dockerfile:" | \ + sed 's/.*dockerfile:[[:space:]]*\(.*\)/\1/' | \ + tr -d '"' | tr -d "'" | xargs) + + CONTEXT_REL=$(echo "$BUILD_OUTPUT" | grep "context:" | \ + sed 's/.*context:[[:space:]]*\(.*\)/\1/' | \ + tr -d '"' | tr -d "'" | xargs) + + if [ -z "$CONTEXT_REL" ]; then + CONTEXT_REL=".." + fi + + if [[ "$CONTEXT_REL" == ".." ]]; then + BUILD_CONTEXT="$PROJECT_ROOT" + elif [[ "$CONTEXT_REL" == /* ]]; then + BUILD_CONTEXT="$CONTEXT_REL" + else + BUILD_CONTEXT="$DEPLOYMENT_DIR/$CONTEXT_REL" + fi + + if [ -n "$DOCKERFILE_REL" ]; then + if [[ "$DOCKERFILE_REL" == /* ]]; then + DOCKERFILE="$DOCKERFILE_REL" + else + if [[ "$CONTEXT_REL" == ".." ]] || [[ "$BUILD_CONTEXT" == "$PROJECT_ROOT" ]]; then + DOCKERFILE="$PROJECT_ROOT/$DOCKERFILE_REL" + else + DOCKERFILE="$BUILD_CONTEXT/$DOCKERFILE_REL" + fi + fi + else + if [ -f "$DEPLOYMENT_DIR/Dockerfile.$SERVICE" ]; then + DOCKERFILE="$DEPLOYMENT_DIR/Dockerfile.$SERVICE" + elif [ -f "$DEPLOYMENT_DIR/$SERVICE/Dockerfile" ]; then + DOCKERFILE="$DEPLOYMENT_DIR/$SERVICE/Dockerfile" + else + error "Could not determine Dockerfile for $SERVICE" + fi + fi + + if docker buildx build \ + --platform "$PLATFORM" \ + --file "$DOCKERFILE" \ + --tag "$IMAGE_TAG" \ + --load \ + "$BUILD_CONTEXT"; then + echo -e " ${GREEN}✓${NC} Built ${CYAN}$SERVICE${NC}" + BUILT_IMAGES+=("$IMAGE_TAG") + echo "" + else + error "Build failed for $SERVICE" + exit 1 + fi +done + +success "Build complete! ${#BUILT_IMAGES[@]} image(s) ready" + +# ============================================================================ +# DEPLOY PHASE +# ============================================================================ + +echo -e "\n${MAGENTA}${BOLD}🚀 Deploying to ${SERVER}${NC}\n" + + +# Check docker pussh +if ! docker pussh --help > /dev/null 2>&1; then + error "docker pussh plugin not installed" + echo " Install: npm run install:pussh" +fi + +# Detect images +IMAGES=($(docker images --format "{{.Repository}}:{{.Tag}}" | grep "^${PROJECT_NAME}-" || true)) + +if [ ${#IMAGES[@]} -eq 0 ]; then + error "No ${PROJECT_NAME} images found" +fi + +# Pre-pull unregistry image if needed +UNREGISTRY_IMAGE="ghcr.io/psviderski/unregistry:0.3.1" +if ! ssh "$SERVER" "docker images --format '{{.Repository}}:{{.Tag}}' | grep -q '^${UNREGISTRY_IMAGE}$'" 2>/dev/null; then + substep "Pulling unregistry image (one-time setup)..." + ssh "$SERVER" "docker pull ${UNREGISTRY_IMAGE}" > /dev/null 2>&1 || true +fi + +# Transfer images +step "Transferring images" +PUSH_FAILED=0 +for IMAGE in "${IMAGES[@]}"; do + substep "Pushing ${CYAN}$IMAGE${NC}..." + if docker pussh "$IMAGE" "$SERVER"; then + echo "" + else + echo -e " ${RED}✗${NC} Failed to push ${CYAN}$IMAGE${NC}" + PUSH_FAILED=1 + echo "" + fi +done + +if [ $PUSH_FAILED -eq 1 ]; then + error "Image transfer failed" +fi + +# Transfer files +step "Transferring deployment files" + +# Ensure destination directory exists with proper permissions +if [ -n "$SUDO_PASSWORD" ]; then + ssh "$SERVER" bash << REMOTE_SUDO_SCRIPT > /dev/null 2>&1 +set -e +echo '$SUDO_PASSWORD' | sudo -S -p '' mkdir -p $DEPLOY_PATH/deployment 2>/dev/null || true +echo '$SUDO_PASSWORD' | sudo -S -p '' chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment 2>/dev/null || true +REMOTE_SUDO_SCRIPT +else + ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment" > /dev/null 2>&1 || true +fi + +# Copy deployment directory excluding gitignored files +cd "$PROJECT_ROOT" +substep "Copying deployment directory..." + +# Generate exclude file for rsync using git ls-files to list ignored files +EXCLUDE_FILE="/tmp/fromchat-rsync-exclude-$$" +RSYNC_ERROR="/tmp/fromchat-rsync-error-$$" + +# Get ignored files in deployment directory and convert to rsync exclude patterns +git ls-files --others --ignored --exclude-standard deployment/ 2>/dev/null | \ + sed 's|^deployment/||' > "$EXCLUDE_FILE" || true + +# Use rsync with native --exclude-from option +if rsync -avz --delete --exclude-from="$EXCLUDE_FILE" \ + "$DEPLOYMENT_DIR/" \ + "$SERVER:$DEPLOY_PATH/deployment/" > "$RSYNC_ERROR" 2>&1; then + rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR" +else + echo -e " ${RED}✗${NC} Rsync failed. Error output:" + cat "$RSYNC_ERROR" | sed 's/^/ /' + rm -f "$EXCLUDE_FILE" "$RSYNC_ERROR" + echo -n " " && error "Failed to copy deployment directory" +fi + +# Copy .env.prod to .env on server (bypassing gitignore) +if [ -f "$DEPLOYMENT_DIR/.env.prod" ]; then + substep "Copying .env.prod to .env..." + if ! scp "$DEPLOYMENT_DIR/.env.prod" "$SERVER:$DEPLOY_PATH/deployment/.env" > /dev/null 2>&1; then + warning "Failed to copy .env.prod to .env" + fi +else + warning ".env.prod not found in deployment directory" +fi + +# Deploy on server +step "Deploying on server" +ssh "$SERVER" SUDO_PASSWORD="$SUDO_PASSWORD" DEPLOY_PATH="$DEPLOY_PATH" bash << 'REMOTE_SCRIPT' +set -e + +REMOTE_SUDO_PASS="${SUDO_PASSWORD:-}" +REMOTE_DEPLOY_PATH="${DEPLOY_PATH:-}" +export SUDO_PROMPT="" + +sudo_cmd() { + if [ -n "$REMOTE_SUDO_PASS" ]; then + echo "$REMOTE_SUDO_PASS" | sudo -S -p '' "$@" 2>/dev/null + else + sudo "$@" 2>/dev/null + fi +} + +if [ -z "$REMOTE_DEPLOY_PATH" ]; then + echo "❌ DEPLOY_PATH is not set" + exit 1 +fi + +mkdir -p "$REMOTE_DEPLOY_PATH/deployment" +cd "$REMOTE_DEPLOY_PATH/deployment" + +if [ ! -f "$REMOTE_DEPLOY_PATH/deployment/.env" ]; then + echo "⚠️ Warning: .env file not found" +fi + +if systemctl is-active --quiet fromchat; then + sudo_cmd systemctl stop fromchat +fi + +docker compose down > /dev/null 2>&1 || true + +sudo_cmd cp -f "$REMOTE_DEPLOY_PATH/deployment/fromchat.service" /etc/systemd/system/fromchat.service +sudo_cmd systemctl daemon-reload +sudo_cmd systemctl restart fromchat + +sleep 3 +if ! systemctl is-active --quiet fromchat; then + echo "❌ Service failed to start" + sudo_cmd journalctl --no-pager -xeu fromchat -n 30 + exit 1 +fi +REMOTE_SCRIPT + +echo +success "Deployment complete!" diff --git a/scripts/generate:env.sh b/scripts/generate:env.sh index 31a890a..0f1df8e 100755 --- a/scripts/generate:env.sh +++ b/scripts/generate:env.sh @@ -8,4 +8,6 @@ cat >> deployment/.env < TURN_SECRET= +DEPLOYMENT_SERVER= +FIREBASE_CERT= EOF diff --git a/scripts/install:pussh.sh b/scripts/install:pussh.sh new file mode 100755 index 0000000..361f9ed --- /dev/null +++ b/scripts/install:pussh.sh @@ -0,0 +1,79 @@ +#!/bin/bash +# Don't use set -e here - we want to continue if Homebrew installation fails + +# Install docker-pussh plugin for Docker CLI +# This script installs the unregistry docker-pussh plugin + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "📦 Installing docker-pussh plugin..." + +# Check if Docker is installed +if ! command -v docker > /dev/null 2>&1; then + echo "⚠️ Docker is not installed. Skipping docker-pussh installation." + exit 0 +fi + +# Create docker plugins directory if it doesn't exist +PLUGIN_DIR="$HOME/.docker/cli-plugins" +mkdir -p "$PLUGIN_DIR" + +# Check if already installed +if [ -f "$PLUGIN_DIR/docker-pussh" ] && docker pussh --help > /dev/null 2>&1; then + echo " ✓ docker-pussh is already installed" + docker pussh --version 2>/dev/null || true + exit 0 +fi + +# Try installing via Homebrew first +if command -v brew > /dev/null 2>&1; then + echo " Attempting installation via Homebrew..." + if brew install psviderski/tap/docker-pussh 2>/dev/null; then + # Create symlink to use as Docker CLI plugin + BREW_PREFIX=$(brew --prefix 2>/dev/null || echo "/opt/homebrew") + if [ -f "$BREW_PREFIX/bin/docker-pussh" ]; then + mkdir -p "$PLUGIN_DIR" + ln -sf "$BREW_PREFIX/bin/docker-pussh" "$PLUGIN_DIR/docker-pussh" 2>/dev/null || true + + # Verify installation + if docker pussh --help > /dev/null 2>&1; then + echo " ✓ docker-pussh installed successfully via Homebrew" + docker pussh --version 2>/dev/null || true + exit 0 + fi + fi + fi + echo " ⚠️ Homebrew installation failed or incomplete, trying direct download..." +fi + +# Fallback: Download and install docker-pussh directly (using latest from main branch) +echo " Downloading docker-pussh from unregistry..." +if curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh \ + -o "$PLUGIN_DIR/docker-pussh" 2>/dev/null; then + chmod +x "$PLUGIN_DIR/docker-pussh" + + # Verify installation + if docker pussh --help > /dev/null 2>&1; then + echo " ✓ docker-pussh installed successfully" + docker pussh --version 2>/dev/null || true + else + echo " ⚠️ Installation completed but plugin verification failed" + echo " You may need to restart your terminal or Docker Desktop" + fi +else + echo " ⚠️ Failed to download docker-pussh" + echo " You can install it manually:" + echo "" + echo " Via Homebrew:" + echo " brew install psviderski/tap/docker-pussh" + echo " mkdir -p ~/.docker/cli-plugins" + echo " ln -sf \$(brew --prefix)/bin/docker-pussh ~/.docker/cli-plugins/docker-pussh" + echo "" + echo " Or via direct download:" + echo " mkdir -p ~/.docker/cli-plugins" + echo " curl -sSL https://raw.githubusercontent.com/psviderski/unregistry/main/docker-pussh \\" + echo " -o ~/.docker/cli-plugins/docker-pussh" + echo " chmod +x ~/.docker/cli-plugins/docker-pussh" + exit 0 +fi + diff --git a/scripts/transfer:unregistry.sh b/scripts/transfer:unregistry.sh new file mode 100755 index 0000000..8e5b8c5 --- /dev/null +++ b/scripts/transfer:unregistry.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Transfer unregistry image from local machine to server +# Usage: ./scripts/transfer:unregistry.sh [server_user@server_host] + +set -e + +SERVER="${1:-${DEPLOY_SERVER:-}}" + +if [ -z "$SERVER" ]; then + echo "❌ Server not specified. Usage: $0 [user@host]" + echo " Or set DEPLOY_SERVER environment variable" + exit 1 +fi + +echo "📦 Transferring unregistry image to $SERVER..." + +# Pull the image locally if not already present +if ! docker images ghcr.io/psviderski/unregistry:0.3.1 --format "{{.Repository}}:{{.Tag}}" | grep -q "unregistry:0.3.1"; then + echo " Pulling unregistry:0.3.1 locally..." + docker pull ghcr.io/psviderski/unregistry:0.3.1 +fi + +# Save and transfer the image +echo " Saving and transferring image to server..." +docker save ghcr.io/psviderski/unregistry:0.3.1 | ssh "$SERVER" "docker load" + +echo " ✓ Unregistry image transferred successfully" +echo "" +echo " Now you can run on the server:" +echo " docker run -d \\" +echo " --name unregistry \\" +echo " -p 5000:5000 \\" +echo " -v /run/containerd/containerd.sock:/run/containerd/containerd.sock \\" +echo " --restart unless-stopped \\" +echo " ghcr.io/psviderski/unregistry:0.3.1" + +