From 89daf2718b14c32b5c25abf9cd5045be9a8581d4 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 8 Nov 2025 20:05:21 +0300 Subject: [PATCH 01/59] Add better profanity filter, better logging, admin CLI --- SECURITY_FOLLOWUPS.md | 9 + backend/admin_cli.py | 388 +++++++++++++++++++ backend/app.py | 64 ++- backend/db.py | 32 +- backend/dependencies.py | 14 +- backend/logging_config.py | 85 ++++ backend/requirements.txt | 2 + backend/routes/account.py | 142 ++++++- backend/routes/messaging.py | 338 +++++++++++++--- backend/routes/moderation.py | 60 +++ backend/routes/profile.py | 113 +++++- backend/security/__init__.py | 2 + backend/security/audit.py | 370 ++++++++++++++++++ backend/security/profanity.py | 149 +++++++ deployment/Dockerfile.backend | 7 +- deployment/docker-compose.yml | 8 +- frontend/src/pages/chat/ui/right/Message.tsx | 8 +- package.json | 3 +- 18 files changed, 1699 insertions(+), 95 deletions(-) create mode 100644 SECURITY_FOLLOWUPS.md create mode 100644 backend/admin_cli.py create mode 100644 backend/logging_config.py create mode 100644 backend/routes/moderation.py create mode 100644 backend/security/__init__.py create mode 100644 backend/security/audit.py create mode 100644 backend/security/profanity.py diff --git a/SECURITY_FOLLOWUPS.md b/SECURITY_FOLLOWUPS.md new file mode 100644 index 0000000..659f5d1 --- /dev/null +++ b/SECURITY_FOLLOWUPS.md @@ -0,0 +1,9 @@ +# Security Follow-ups + +- Integrate log shipping/alerting (e.g. Loki or ELK) so events from `backend/logs/*.log` raise actionable notifications instead of remaining on disk. +- Add automated review of `security.log` for repeated `auth_bruteforce_detected` and burst messaging entries; trigger temporary IP bans or captcha challenges when thresholds are exceeded. +- Extend profanity filtering tests to cover dynamic blocklist updates and multi-language phrases; add regression suite to ensure adult-content words remain blocked. +- Implement DM spam heuristics similar to public chat (rate limiting, reaction abuse detection) and log attempts that target users who blocked the sender. +- Harden WebSocket session handling by recycling DB sessions per request or adopting async session factories to keep long-lived connections from retaining database handles indefinitely. +- Wire the new moderator blocklist endpoints into an authenticated UI workflow so operators can manage entries without shell access, and audit every change with responsible operator metadata. + diff --git a/backend/admin_cli.py b/backend/admin_cli.py new file mode 100644 index 0000000..cfd573f --- /dev/null +++ b/backend/admin_cli.py @@ -0,0 +1,388 @@ +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_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.", + "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 == "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:8301/api"), + 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..b4ed2ad 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,16 +1,18 @@ -from fastapi import FastAPI +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 db import POOL_CONFIG, SessionLocal +from logging_config import access_logger # noqa: F401 - ensure loggers configured +from security.audit import log_access logger = logging.getLogger("uvicorn.error") @@ -33,11 +35,7 @@ async def lifespan(app: FastAPI): 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() if owner and not owner.verified: owner.verified = True @@ -47,10 +45,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 @@ -66,6 +72,41 @@ async def lifespan(app: FastAPI): # Инициализация FastAPI app = FastAPI(title="FromChat", lifespan=lifespan) + +@app.middleware("http") +async def access_logging_middleware(request: Request, call_next): + 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=request.client.host if request.client else None, + 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=request.headers.get("x-forwarded-for") or (request.client.host if request.client else None), + duration=f"{duration:.3f}s", + ) + return response + + # CORS app.add_middleware( CORSMiddleware, @@ -89,4 +130,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/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..5bdfcab 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,4 +1,4 @@ -from fastapi import Depends, HTTPException, status +from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session from utils import * @@ -17,8 +17,9 @@ 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) @@ -36,6 +37,12 @@ def get_current_user( 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: @@ -77,4 +84,7 @@ def get_current_user( 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..d8b28dd --- /dev/null +++ b/backend/logging_config.py @@ -0,0 +1,85 @@ +import logging +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") + 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._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/requirements.txt b/backend/requirements.txt index 3cf107f..eac7bc3 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -11,3 +11,5 @@ 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 diff --git a/backend/routes/account.py b/backend/routes/account.py index ff8cc26..cd49c63 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,33 @@ 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 validation import is_valid_password, is_valid_username, is_valid_display_name import os +from security.audit import log_security 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 convert_user(user: User) -> dict: return { "id": user.id, @@ -43,18 +64,51 @@ def check_auth(current_user: User = Depends(get_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() +def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): + username = request.username.strip() + x_forwarded_for = http.headers.get("x-forwarded-for") if http else None + client_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (http.client.host if http and http.client else None) + + user = db.query(User).filter(User.username == username).first() if not user or not verify_password(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 = http.headers.get("user-agent") + device_name = http.headers.get("x-device-name") ua = parse_ua(raw_ua or "") session_id = uuid.uuid4().hex @@ -82,6 +136,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,11 +162,12 @@ 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): +def register(request: RegisterRequest, http: Request, db: Session = Depends(get_db)): username = request.username.strip() display_name = request.display_name.strip() password = request.password.strip() confirm_password = request.confirm_password.strip() + client_ip = http.client.host if http.client else None # Determine if owner already exists owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None @@ -165,8 +237,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 = http.headers.get("user-agent") + device_name = http.headers.get("x-device-name") ua = parse_ua(raw_ua or "") session_id = uuid.uuid4().hex device = DeviceSession( @@ -190,6 +262,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": "Регистрация прошла успешно", @@ -264,10 +354,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 +385,15 @@ def logout( current_user.last_seen = datetime.now() db.commit() + client_ip = http.client.host if http.client else None + 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" @@ -294,6 +403,7 @@ def logout( @router.post("/change-password") def change_password( request: ChangePasswordRequest, + http: Request, credentials: HTTPAuthorizationCredentials = Depends(HTTPBearer()), current_user: User = Depends(get_current_user), db: Session = Depends(get_db) @@ -319,6 +429,15 @@ def change_password( ).update({DeviceSession.revoked: True}) db.commit() + client_ip = http.client.host if http.client else None + log_security( + "password_changed", + username=current_user.username, + user_id=current_user.id, + ip=client_ip, + logout_others=bool(request.logoutAllExceptCurrent), + ) + return {"status": "success"} @@ -430,6 +549,13 @@ async def delete_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/messaging.py b/backend/routes/messaging.py index 208af07..7f4aea3 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,6 +7,10 @@ import re import uuid import asyncio import time +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 from fastapi.responses import FileResponse from fastapi.security import HTTPAuthorizationCredentials @@ -19,6 +24,8 @@ from PIL import Image import io import json from better_profanity import profanity as _bp +from security.audit import log_access, log_dm, log_public_chat, log_security +from security.profanity import censor_text router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -32,6 +39,68 @@ 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 + +_recent_message_cache: dict[int, deque[tuple[str, float]]] = defaultdict(deque) +_message_rate_cache: dict[int, deque[float]] = defaultdict(deque) +_burst_last_logged: dict[int, float] = {} + + +def _monitor_public_message_activity(user: User, content: str, db: Session) -> None: + now = time.time() + + # Rate tracking for burst detection + rate_bucket = _message_rate_cache[user.id] + rate_bucket.append(now) + while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS: + rate_bucket.popleft() + + if len(rate_bucket) >= _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=len(rate_bucket), + window_seconds=_BURST_WINDOW_SECONDS, + ) + _burst_last_logged[user.id] = now + + # Similarity-based spam detection + history = _recent_message_cache[user.id] + history.append((content, now)) + while history and now - history[0][1] > _SPAM_WINDOW_SECONDS: + history.popleft() + + similar_messages = sum( + 1 for previous_content, _ in history + if SequenceMatcher(None, content, previous_content).ratio() >= _SPAM_SIMILARITY_THRESHOLD + ) + + if similar_messages >= _SPAM_MESSAGE_LIMIT and not user.suspended and user.id != 1: + reason = "Automatic suspension: repeated similar public messages" + user.suspended = True + user.suspension_reason = reason + db.commit() + log_security( + "auto_suspension_public_spam", + severity="warning", + user_id=user.id, + username=user.username, + similar_messages=similar_messages, + window_seconds=_SPAM_WINDOW_SECONDS, + ) + try: + asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason)) + except Exception: + pass + def convert_message(msg: Message) -> dict: # Group reactions by emoji @@ -138,46 +207,6 @@ 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, @@ -204,23 +233,26 @@ async def send_message( if not original_message: raise HTTPException(status_code=404, detail="Original message not found") - if not request.content.strip(): + raw_content = 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()) + filtered_content = censor_text(raw_content) + escaped_content = html.escape(filtered_content, quote=False) - if len(filtered_content) > 4096: + 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, timestamp=datetime.now() @@ -293,7 +325,6 @@ 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) @@ -301,7 +332,22 @@ async def send_message( except Exception: pass - return {"status": "success", "message": convert_message(new_message)} + _monitor_public_message_activity(current_user, filtered_content, db) + + message_payload = convert_message(new_message) + log_public_chat( + "message_created", + 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, + ) + + return {"status": "success", "message": message_payload} @router.get("/get_messages") @@ -404,6 +450,7 @@ async def dm_send( ) db.add(df) db.commit() + db.refresh(env) # Send push notification for DM try: @@ -433,6 +480,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]): @@ -531,15 +588,35 @@ async def edit_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 = 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 + sanitized_content = censor_text(raw_content) + escaped_content = html.escape(sanitized_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) + log_public_chat( + "message_edited", + 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, + ) + + return {"status": "success", "message": payload} @router.delete("/delete_message/{message_id}") @@ -557,9 +634,19 @@ 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} @@ -600,9 +687,10 @@ 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": { @@ -611,13 +699,22 @@ async def add_reaction( "action": action, "user_id": current_user.id, "username": current_user.username, - "reactions": convert_message(message)["reactions"] + "reactions": message_data["reactions"] } }) except Exception: pass - return {"status": "success", "action": action, "reactions": convert_message(message)["reactions"]} + log_public_chat( + "reaction_update", + message_id=request.message_id, + user_id=current_user.id, + username=current_user.username, + action=action, + emoji=request.emoji, + ) + + return {"status": "success", "action": action, "reactions": message_data["reactions"]} @router.post("/dm/add_reaction") @@ -661,6 +758,8 @@ async def add_dm_reaction( # Refresh envelope to get updated reactions db.refresh(envelope) + envelope_data = convert_dm_envelope(envelope) + # Broadcast reaction update to both participants try: await messagingManager.broadcast({ @@ -671,13 +770,22 @@ async def add_dm_reaction( "action": action, "user_id": current_user.id, "username": current_user.username, - "reactions": convert_dm_envelope(envelope)["reactions"] + "reactions": envelope_data["reactions"] } }) except Exception: pass - return {"status": "success", "action": action, "reactions": convert_dm_envelope(envelope)["reactions"]} + log_dm( + "reaction_update", + dm_envelope_id=request.dm_envelope_id, + user_id=current_user.id, + username=current_user.username, + action=action, + emoji=request.emoji, + ) + + return {"status": "success", "action": action, "reactions": envelope_data["reactions"]} class MessaggingSocketManager: @@ -697,13 +805,37 @@ class MessaggingSocketManager: # Initialize subscriptions for this connection self.ws_subscriptions[websocket] = set() + 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) + + def _log_ws(event: str, user: User | None, **extra: Any) -> 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, + ) + while True: data = await websocket.receive_json() type = data["type"] def get_current_user_inner() -> User | None: if data["credentials"]: + dummy_request = SimpleNamespace() + dummy_request.state = SimpleNamespace() return get_current_user( + dummy_request, HTTPAuthorizationCredentials( scheme=data["credentials"]["scheme"], credentials=data["credentials"]["credentials"] @@ -714,6 +846,7 @@ class MessaggingSocketManager: return None if type == "ping": + current_user: User | None = None try: current_user = get_current_user_inner() if current_user: @@ -737,6 +870,7 @@ class MessaggingSocketManager: } } }) + _log_ws("ping_error", current_user) except HTTPException: await websocket.send_json({ "type": "ping", @@ -748,8 +882,11 @@ class MessaggingSocketManager: } } }) + _log_ws("ping_error", current_user) await websocket.send_json({"type": "ping", "data": {"status": "success"}}) + _log_ws("ping", current_user) elif type == "getMessages": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -757,9 +894,12 @@ class MessaggingSocketManager: self.user_by_ws[websocket] = current_user.id await websocket.send_json({"type": type, "data": await get_messages(current_user, db)}) + _log_ws("getMessages", current_user) except HTTPException as e: + _log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "sendMessage": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -775,9 +915,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("sendMessage", current_user, message_id=response["message"]["id"]) except HTTPException as e: + _log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "dmSend": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -827,9 +970,21 @@ class MessaggingSocketManager: 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); + + _log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id) + log_dm( + "message_sent_ws", + dm_envelope_id=env.id, + sender_id=current_user.id, + sender_username=current_user.username, + recipient_id=env.recipient_id, + reply_to=env.reply_to_id, + ) except HTTPException as e: + _log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "editMessage": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -845,9 +1000,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("editMessage", current_user, message_id=message_id) except HTTPException as e: + _log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "dmEdit": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -887,9 +1045,19 @@ class MessaggingSocketManager: 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}}) + + _log_ws("dmEdit", current_user, dm_envelope_id=env.id) + log_dm( + "message_edited", + dm_envelope_id=env.id, + user_id=current_user.id, + username=current_user.username, + ) except HTTPException as e: + _log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "dmDelete": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -917,9 +1085,20 @@ class MessaggingSocketManager: 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) + + _log_ws("dmDelete", current_user, dm_envelope_id=env_id) + log_dm( + "message_deleted", + dm_envelope_id=env_id, + user_id=current_user.id, + username=current_user.username, + recipient_id=env.recipient_id, + ) except HTTPException as e: + _log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "deleteMessage": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -933,9 +1112,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("deleteMessage", current_user, message_id=message_id) except HTTPException as e: + _log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "addReaction": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -963,9 +1145,12 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"]) except HTTPException as e: + _log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "addDmReaction": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -993,10 +1178,13 @@ class MessaggingSocketManager: }) await websocket.send_json({"type": type, "data": response}) + _log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"]) except HTTPException as e: + _log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "call_signaling": # Forward WebRTC signaling between peers + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1019,10 +1207,13 @@ class MessaggingSocketManager: # Optional ack await websocket.send_json({"type": "call_signaling", "data": {"status": "ok"}}) + _log_ws("call_signaling", current_user, to_user_id=to_user_id) except HTTPException as e: + _log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) elif type == "call_video_toggle": # Forward video toggle state between peers + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1049,9 +1240,13 @@ class MessaggingSocketManager: await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("call_video_toggle_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("call_video_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) elif type == "call_screen_share_toggle": # Forward screen share toggle state between peers + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1078,8 +1273,12 @@ class MessaggingSocketManager: await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("call_screen_share_toggle_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("call_screen_share_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) elif type == "subscribeStatus": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1096,7 +1295,7 @@ class MessaggingSocketManager: "data": { "userId": user_id_to_subscribe, "online": target_user.online, - "lastSeen": target_user.last_seen.isoformat() + "lastSeen": target_user.last_seen.isoformat() if target_user.last_seen else None } }) else: @@ -1105,8 +1304,12 @@ class MessaggingSocketManager: "data": {"status": "error", "error": "User not found"} }) except HTTPException as e: + _log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe) elif type == "unsubscribeStatus": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1117,8 +1320,12 @@ class MessaggingSocketManager: await websocket.send_json({"type": "unsubscribeStatus", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe) elif type == "typing": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1137,8 +1344,12 @@ class MessaggingSocketManager: await websocket.send_json({"type": "typing", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("typing", current_user) elif type == "stopTyping": + current_user: User | None = None try: current_user = get_current_user_inner() if not current_user: @@ -1158,7 +1369,10 @@ class MessaggingSocketManager: await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}}) except HTTPException as e: + _log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) + else: + _log_ws("stopTyping", current_user) elif type == "dmTyping": try: current_user = get_current_user_inner() @@ -1219,11 +1433,25 @@ 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) 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: # Cleanup connection self.connections.remove(websocket) diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py new file mode 100644 index 0000000..071432c --- /dev/null +++ b/backend/routes/moderation.py @@ -0,0 +1,60 @@ +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 + + +class BlocklistUpdateRequest(BaseModel): + words: List[str] = Field(default_factory=list, min_items=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} + diff --git a/backend/routes/profile.py b/backend/routes/profile.py index ccba31b..388de5c 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -3,7 +3,6 @@ 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 @@ -15,9 +14,18 @@ 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 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 @@ -104,15 +112,53 @@ 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 False, + ) + + +@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 False, + ).model_dump() + for user in users + ] } @router.put("/user/profile") @@ -214,6 +260,8 @@ async def get_user_by_username( if not user: raise HTTPException(status_code=404, detail="User not found") + + _ensure_owner_unsuspended(user, db) return UserProfileResponse( id=user.id, @@ -223,7 +271,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 False, ) @router.get("/user/id/{user_id}") @@ -238,6 +290,8 @@ async def get_user_by_id( if not user: raise HTTPException(status_code=404, detail="User not found") + + _ensure_owner_unsuspended(user, db) # Handle deleted users if user.deleted: @@ -293,6 +347,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 +426,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 +471,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 +506,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..ded1215 --- /dev/null +++ b/backend/security/audit.py @@ -0,0 +1,370 @@ +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)}", + f"Similar messages detected: {fields.get('similar_messages')}", + ] + if fields.get("window_seconds"): + lines.append(f"Observation window: {fields['window_seconds']} seconds") + 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 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 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..b1f3d62 --- /dev/null +++ b/backend/security/profanity.py @@ -0,0 +1,149 @@ +from __future__ import annotations + +import json +import re +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)) + +_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), +) + +_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() + combined = set(_STATIC_TERMS) + combined.update(blocklist_list) + if combined: + profanity.add_censor_words(list(combined)) + + _profanity = profanity + _blocklist_signature = signature + + +def _apply_phrase_filters(text: str) -> str: + result = text + for pattern in _PHRASE_PATTERNS: + while True: + match = pattern.search(result) + if not match: + break + result = result[:match.start()] + ("\\*" * (match.end() - match.start())) + result[match.end():] + return result + + +def censor_text(text: str) -> str: + if not text: + return text + + _rebuild_dictionary() + preprocessed = _apply_phrase_filters(text) + return _profanity.censor(preprocessed, censor_char="\\*") + + +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/deployment/Dockerfile.backend b/deployment/Dockerfile.backend index daf7c5b..61e3e1f 100644 --- a/deployment/Dockerfile.backend +++ b/deployment/Dockerfile.backend @@ -14,12 +14,15 @@ 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 && \ + 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/docker-compose.yml b/deployment/docker-compose.yml index a0cd804..de7e115 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -9,7 +9,9 @@ services: VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY} VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY} volumes: - - "data:/app/data" + - data:/app/data + - logs:/app/logs + develop: watch: - action: sync+restart @@ -41,4 +43,6 @@ services: volumes: data: - name: fromchat-data \ No newline at end of file + name: fromchat-data + logs: + name: fromchat-logs \ No newline at end of file diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 9423457..757cdd9 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -3,7 +3,7 @@ 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"; @@ -170,7 +170,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD 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]); diff --git a/package.json b/package.json index f8f8362..d59d2b4 100644 --- a/package.json +++ b/package.json @@ -44,6 +44,7 @@ "@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", @@ -64,9 +65,9 @@ "vite-plugin-sass-dts": "^1.3.34" }, "dependencies": { - "dompurify": "^3.2.7", "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", + "he": "^1.2.0", "marked": "^16.3.0", "mdui": "^2.1.4", "motion": "^12.23.24", From 66f6d17a2a400a4e80bd062a5a83014b2b529d85 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 9 Nov 2025 15:47:28 +0300 Subject: [PATCH 02/59] Add profanity in display and usernames, fix Docker setup --- backend/routes/account.py | 11 ++ backend/routes/messaging.py | 87 ++++++++++++---- backend/routes/profile.py | 11 ++ backend/security/audit.py | 20 +++- backend/security/profanity.py | 190 +++++++++++++++++++++++++++++++++- deployment/.dockerignore | 3 +- 6 files changed, 296 insertions(+), 26 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index cd49c63..7784c0f 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -16,6 +16,7 @@ from validation import is_valid_password, is_valid_username, is_valid_display_na import os from security.audit import log_security +from security.profanity import contains_profanity router = APIRouter() _FAILED_ATTEMPT_WINDOW_SECONDS = 300 @@ -185,12 +186,22 @@ def register(request: RegisterRequest, http: Request, db: Session = Depends(get_ 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( diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 7f4aea3..1f2d64d 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -7,6 +7,7 @@ import re import uuid import asyncio import time +import unicodedata from collections import defaultdict, deque from difflib import SequenceMatcher from types import SimpleNamespace @@ -44,22 +45,51 @@ _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, float]]] = defaultdict(deque) +_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque) _message_rate_cache: dict[int, deque[float]] = defaultdict(deque) _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, db: Session) -> None: now = time.time() + def suspend(reason: str, event: str, **extra: Any) -> None: + if user.suspended or user.id == 1: + return + user.suspended = True + user.suspension_reason = reason + db.commit() + log_security( + event, + severity="warning", + user_id=user.id, + username=user.username, + reason=reason, + **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) while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS: rate_bucket.popleft() - if len(rate_bucket) >= _BURST_COUNT_THRESHOLD: + 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( @@ -67,39 +97,52 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N severity="warning", user_id=user.id, username=user.username, - count=len(rate_bucket), + count=burst_count, window_seconds=_BURST_WINDOW_SECONDS, ) _burst_last_logged[user.id] = now + suspend( + "Automatic suspension: excessive message rate", + "auto_suspension_public_burst", + count=burst_count, + window_seconds=_BURST_WINDOW_SECONDS, + ) # Similarity-based spam detection + normalized = _normalize_for_spam(content) history = _recent_message_cache[user.id] - history.append((content, now)) - while history and now - history[0][1] > _SPAM_WINDOW_SECONDS: + while history and now - history[0][2] > _SPAM_WINDOW_SECONDS: history.popleft() - similar_messages = sum( - 1 for previous_content, _ in history - if SequenceMatcher(None, content, previous_content).ratio() >= _SPAM_SIMILARITY_THRESHOLD + 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 ) - if similar_messages >= _SPAM_MESSAGE_LIMIT and not user.suspended and user.id != 1: - reason = "Automatic suspension: repeated similar public messages" - user.suspended = True - user.suspension_reason = reason - db.commit() - log_security( + history.append((normalized, content, now)) + + total_matches = prior_same + prior_similar + 1 + + if len(normalized) <= _SHORT_MESSAGE_LENGTH and prior_same + 1 >= _SHORT_MESSAGE_REPEAT_LIMIT: + suspend( + "Automatic suspension: repeated short messages", "auto_suspension_public_spam", - severity="warning", - user_id=user.id, - username=user.username, - similar_messages=similar_messages, + occurrences=prior_same + 1, window_seconds=_SPAM_WINDOW_SECONDS, + match_type="short", + ) + return + + if total_matches >= _SPAM_MESSAGE_LIMIT: + suspend( + "Automatic suspension: repeated similar public messages", + "auto_suspension_public_spam", + similar_messages=total_matches, + window_seconds=_SPAM_WINDOW_SECONDS, + match_type="similar", ) - try: - asyncio.create_task(messagingManager.send_suspension_to_user(user.id, reason)) - except Exception: - pass def convert_message(msg: Message) -> dict: diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 388de5c..bcf5716 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -15,6 +15,7 @@ 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 router = APIRouter() @@ -180,6 +181,11 @@ async def update_user_profile( 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() @@ -197,6 +203,11 @@ async def update_user_profile( 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 diff --git a/backend/security/audit.py b/backend/security/audit.py index ded1215..acf8766 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -145,10 +145,28 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]: if action == "auto_suspension_public_spam": lines = [ f"Automatic suspension triggered for {_format_user(fields)}", - f"Similar messages detected: {fields.get('similar_messages')}", ] + 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 [ diff --git a/backend/security/profanity.py b/backend/security/profanity.py index b1f3d62..8c49d30 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -15,7 +15,9 @@ _CUSTOM_RU_TERMS: Set[str] = { "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", - "долбоёб", "долбоеб", "дебил", "член", "проститутка", "урод", + "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", + "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", + "пидоры", "пидорас", "пидорасы", "пидорасов", } _ADULT_TERMS: Set[str] = { @@ -33,8 +35,167 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( 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), ) +_LEET_MAP = { + "0": "о", + "o": "о", + "о": "о", + "a": "а", + "@": "а", + "4": "а", + "а": "а", + "e": "е", + "ё": "е", + "3": "е", + "c": "с", + "s": "с", + "с": "с", + "x": "х", + "х": "х", + "t": "т", + "т": "т", + "p": "п", + "п": "п", + "n": "н", + "н": "н", + "m": "м", + "м": "м", + "y": "у", + "u": "у", + "у": "у", + "g": "г", + "г": "г", + "v": "в", + "в": "в", + "f": "ф", + "ф": "ф", + "i": "и", + "1": "и", + "и": "и", +} + +_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 _normalize_char(ch: str) -> str: + lower = ch.lower() + return _LEET_MAP.get(lower, lower) + + +def _normalize_token(token: str) -> str: + return "".join(_normalize_char(ch) for ch in token) + + +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() @@ -96,7 +257,11 @@ def _apply_phrase_filters(text: str) -> str: match = pattern.search(result) if not match: break - result = result[:match.start()] + ("\\*" * (match.end() - match.start())) + result[match.end():] + result = result[:match.start()] + ("*" * (match.end() - match.start())) + result[match.end():] + + for start, end in sorted(_find_fuzzy_phrase_spans(text, "generic"), reverse=True): + result = result[:start] + ("*" * (end - start)) + result[end:] + return result @@ -109,6 +274,27 @@ def censor_text(text: str) -> str: return _profanity.censor(preprocessed, censor_char="\\*") +def contains_profanity(text: str) -> bool: + if not text: + return False + + _rebuild_dictionary() + for pattern in _PHRASE_PATTERNS: + if pattern.search(text): + return True + if _find_fuzzy_phrase_spans(text, "generic"): + return True + return _profanity.contains_profanity(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()) 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 From 3d54da68ce542b0b71327f3a64f523968928111b Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 9 Nov 2025 16:17:36 +0300 Subject: [PATCH 03/59] Fix API URL --- backend/admin_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/admin_cli.py b/backend/admin_cli.py index cfd573f..6afd4f8 100644 --- a/backend/admin_cli.py +++ b/backend/admin_cli.py @@ -375,7 +375,7 @@ 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:8301/api"), + 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) From fa76315fcab2038ed97d465fe36af3bf0c2c3b6d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 9 Nov 2025 16:52:35 +0300 Subject: [PATCH 04/59] Fix Docker deployment --- deployment/Dockerfile.backend | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/deployment/Dockerfile.backend b/deployment/Dockerfile.backend index 61e3e1f..e2c4fac 100644 --- a/deployment/Dockerfile.backend +++ b/deployment/Dockerfile.backend @@ -18,7 +18,8 @@ RUN useradd -u 1000 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 From 8f94dda4c3fa082aac9e7daffade92d2967898d0 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 9 Nov 2025 17:08:02 +0300 Subject: [PATCH 05/59] Fix logging --- backend/logging_config.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/backend/logging_config.py b/backend/logging_config.py index d8b28dd..65922ce 100644 --- a/backend/logging_config.py +++ b/backend/logging_config.py @@ -1,4 +1,5 @@ import logging +import os from datetime import datetime from logging.handlers import RotatingFileHandler from pathlib import Path @@ -11,7 +12,7 @@ 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") + 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 @@ -29,6 +30,9 @@ class HumanReadableFileHandler(RotatingFileHandler): 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") From 08c9a7cab70b41f4f02679e2a7ea14f4600076be Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 9 Nov 2025 19:18:05 +0300 Subject: [PATCH 06/59] Use real IP everywhere --- backend/app.py | 5 +++-- backend/routes/account.py | 11 +++++------ backend/utils.py | 27 +++++++++++++++++++++++++-- 3 files changed, 33 insertions(+), 10 deletions(-) diff --git a/backend/app.py b/backend/app.py index b4ed2ad..71a4f75 100644 --- a/backend/app.py +++ b/backend/app.py @@ -9,6 +9,7 @@ from routes import account, messaging, profile, push, webrtc, devices, moderatio import logging from models import User from constants import OWNER_USERNAME +from utils import get_client_ip from db import POOL_CONFIG, SessionLocal from logging_config import access_logger # noqa: F401 - ensure loggers configured @@ -87,7 +88,7 @@ async def access_logging_middleware(request: Request, call_next): path=request.url.path, status="error", user=getattr(user, "username", None), - ip=request.client.host if request.client else None, + ip=get_client_ip(request), duration=f"{duration:.3f}s", error=str(exc), ) @@ -101,7 +102,7 @@ async def access_logging_middleware(request: Request, call_next): path=request.url.path, status=response.status_code, user=getattr(user, "username", None), - ip=request.headers.get("x-forwarded-for") or (request.client.host if request.client else None), + ip=get_client_ip(request), duration=f"{duration:.3f}s", ) return response diff --git a/backend/routes/account.py b/backend/routes/account.py index 7784c0f..d9b482b 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -11,7 +11,7 @@ from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials from constants import OWNER_USERNAME from dependencies import get_current_user, get_db 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 @@ -67,8 +67,7 @@ def check_auth(current_user: User = Depends(get_current_user)): @router.post("/login") def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): username = request.username.strip() - x_forwarded_for = http.headers.get("x-forwarded-for") if http else None - client_ip = x_forwarded_for.split(",")[0].strip() if x_forwarded_for else (http.client.host if http and http.client else None) + client_ip = get_client_ip(http) user = db.query(User).filter(User.username == username).first() @@ -168,7 +167,7 @@ def register(request: RegisterRequest, http: Request, db: Session = Depends(get_ display_name = request.display_name.strip() password = request.password.strip() confirm_password = request.confirm_password.strip() - client_ip = http.client.host if http.client else None + client_ip = get_client_ip(http) # Determine if owner already exists owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None @@ -396,7 +395,7 @@ def logout( current_user.last_seen = datetime.now() db.commit() - client_ip = http.client.host if http.client else None + client_ip = get_client_ip(http) log_security( "logout", username=current_user.username, @@ -440,7 +439,7 @@ def change_password( ).update({DeviceSession.revoked: True}) db.commit() - client_ip = http.client.host if http.client else None + client_ip = get_client_ip(http) log_security( "password_changed", username=current_user.username, diff --git a/backend/utils.py b/backend/utils.py index 09db130..3b6da7e 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -1,6 +1,7 @@ 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 * @@ -31,4 +32,26 @@ 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 + forwarded = headers.get("x-forwarded-for") or headers.get("X-Forwarded-For") + if forwarded: + candidate = forwarded.split(",")[0].strip() + if candidate: + return candidate + + if request.client and request.client.host: + return request.client.host + + 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 From 320442c5fcb9693f9c8b802904820c4391ba8949 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 9 Nov 2025 20:21:40 +0300 Subject: [PATCH 07/59] Fix logging --- backend/security/audit.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/security/audit.py b/backend/security/audit.py index acf8766..52bad60 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -231,7 +231,7 @@ def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: 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:") + lines.append("Previous content:") for line in unescape(fields["content"]).splitlines(): lines.append(f"| {line}") return lines From 0f86e9541c42fb45e368f3f9288c52735727d466 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 10 Nov 2025 16:06:30 +0300 Subject: [PATCH 08/59] Add user agent blocklist, use X-Real-IP header for IP detection --- backend/admin_cli.py | 66 ++++++ backend/app.py | 6 + backend/requirements.txt | 1 + backend/routes/account.py | 35 ++++ backend/routes/messaging.py | 6 + backend/routes/moderation.py | 49 +++++ backend/routes/profile.py | 4 + backend/security/audit.py | 28 +++ backend/security/rate_limit.py | 46 +++++ backend/security/user_agent_blocklist.py | 245 +++++++++++++++++++++++ backend/utils.py | 13 ++ 11 files changed, 499 insertions(+) create mode 100644 backend/security/rate_limit.py create mode 100644 backend/security/user_agent_blocklist.py diff --git a/backend/admin_cli.py b/backend/admin_cli.py index 6afd4f8..0f612e2 100644 --- a/backend/admin_cli.py +++ b/backend/admin_cli.py @@ -276,6 +276,63 @@ class AdminCLI: table.add_row(entry) self.console.print(table) + def cmd_block_user_agent(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: block-user-agent [additional patterns...]") + self._require_auth() + patterns = args + response = self._request("POST", "moderation/user-agent-blocklist", json={"words": patterns}) + data = response.json() + added = data.get("added", []) + current = data.get("patterns", []) + if added: + self.console.print(f"[bold green]Added {len(added)} pattern{'s' if len(added) != 1 else ''} to user agent blocklist.[/]") + else: + self.console.print("[yellow]No new patterns added.[/]") + self.console.print(f"Blocklist size: {len(current)}") + + def cmd_unblock_user_agent(self, args: List[str]) -> None: + if not args: + raise CLIError("Usage: unblock-user-agent [additional patterns...]") + self._require_auth() + response = self._request("DELETE", "moderation/user-agent-blocklist", json={"words": args}) + data = response.json() + removed = data.get("removed", []) + current = data.get("patterns", []) + if removed: + self.console.print(f"[bold green]Removed {len(removed)} pattern{'s' if len(removed) != 1 else ''} from user agent blocklist.[/]") + else: + self.console.print("[yellow]No matching patterns removed.[/]") + self.console.print(f"Blocklist size: {len(current)}") + + def cmd_list_user_agent_blocklist(self) -> None: + self._require_auth() + response = self._request("GET", "moderation/user-agent-blocklist") + data = response.json() + static = data.get("static", []) + external = data.get("external", []) + + if not static and not external: + self.console.print("[cyan]User agent blocklist is empty.[/]") + return + + if static: + table_static = Table(title="Static Blocked User Agent Patterns", show_lines=True) + table_static.add_column("Pattern", style="yellow") + for entry in static: + table_static.add_row(entry) + self.console.print(table_static) + + if external: + table_external = Table(title="External Blocked User Agent Patterns", show_lines=True) + table_external.add_column("Pattern", style="cyan") + for entry in external: + table_external.add_row(entry) + self.console.print(table_external) + + if not external: + self.console.print("[dim]No external patterns. Use 'block-user-agent' to add patterns.[/]") + def cmd_help(self) -> None: cmds = { "login [username]": "Authenticate as owner/admin.", @@ -287,6 +344,9 @@ class AdminCLI: "block-word ": "Add words/phrases to chat filter.", "unblock-word ": "Remove words/phrases from filter.", "blocklist": "Show current blocklist.", + "block-user-agent ": "Add user agent patterns to blocklist.", + "unblock-user-agent ": "Remove user agent patterns from blocklist.", + "user-agent-blocklist": "Show current user agent blocklist.", "list": "List all users.", "user ": "Show detailed user information.", "whoami": "Display current session context.", @@ -347,6 +407,12 @@ class AdminCLI: self.cmd_unblock_word(args) elif command == "blocklist": self.cmd_list_blocklist() + elif command == "block-user-agent": + self.cmd_block_user_agent(args) + elif command == "unblock-user-agent": + self.cmd_unblock_user_agent(args) + elif command == "user-agent-blocklist": + self.cmd_list_user_agent_blocklist() elif command == "verify": self.cmd_verify(args) elif command == "unverify": diff --git a/backend/app.py b/backend/app.py index 71a4f75..1db2b0f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -14,6 +14,8 @@ 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") @@ -73,6 +75,10 @@ async def lifespan(app: FastAPI): # Инициализация 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): diff --git a/backend/requirements.txt b/backend/requirements.txt index eac7bc3..1f6b22a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ better-profanity>=0.7.0 user-agents>=2.2.0 httpx>=0.27.2 rich>=13.9.4 +slowapi>=0.1.9 diff --git a/backend/routes/account.py b/backend/routes/account.py index d9b482b..456e655 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -17,6 +17,8 @@ import os from security.audit import log_security from security.profanity import contains_profanity +from security.user_agent_blocklist import is_user_agent_blocked +from security.rate_limit import rate_limit_per_ip, rate_limit_per_user router = APIRouter() _FAILED_ATTEMPT_WINDOW_SECONDS = 300 @@ -65,9 +67,25 @@ def check_auth(current_user: User = Depends(get_current_user)): @router.post("/login") +@rate_limit_per_ip("5/minute") def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): username = request.username.strip() client_ip = get_client_ip(http) + raw_ua = http.headers.get("user-agent") + + if is_user_agent_blocked(raw_ua): + log_security( + "blocked_user_agent", + severity="warning", + username=username, + ip=client_ip, + user_agent=raw_ua or "Unknown", + action="login", + ) + raise HTTPException( + status_code=403, + detail="Доступ запрещён" + ) user = db.query(User).filter(User.username == username).first() @@ -162,12 +180,28 @@ def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): @router.post("/register") +@rate_limit_per_ip("3/hour") def register(request: RegisterRequest, http: Request, db: Session = Depends(get_db)): username = request.username.strip() display_name = request.display_name.strip() password = request.password.strip() confirm_password = request.confirm_password.strip() client_ip = get_client_ip(http) + raw_ua = http.headers.get("user-agent") + + if is_user_agent_blocked(raw_ua): + log_security( + "blocked_user_agent", + severity="warning", + username=username, + ip=client_ip, + user_agent=raw_ua or "Unknown", + action="registration", + ) + raise HTTPException( + status_code=403, + detail="Доступ запрещён" + ) # Determine if owner already exists owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None @@ -411,6 +445,7 @@ def logout( @router.post("/change-password") +@rate_limit_per_user("5/hour") def change_password( request: ChangePasswordRequest, http: Request, diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 1f2d64d..985492b 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -27,6 +27,7 @@ import json from better_profanity import profanity as _bp from security.audit import log_access, log_dm, log_public_chat, log_security from security.profanity import censor_text +from security.rate_limit import rate_limit_per_user router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -251,6 +252,7 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: } @router.post("/send_message") +@rate_limit_per_user("30/minute") async def send_message( request: SendMessageRequest | None = None, current_user: User = Depends(get_current_user), @@ -408,6 +410,7 @@ async def get_messages(db: Session = Depends(get_db)): @router.post("/dm/send") +@rate_limit_per_user("20/minute") async def dm_send( payload: dict | None = None, current_user: User = Depends(get_current_user), @@ -619,6 +622,7 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d @router.put("/edit_message/{message_id}") +@rate_limit_per_user("20/minute") async def edit_message( message_id: int, request: EditMessageRequest, @@ -694,6 +698,7 @@ async def delete_message( @router.post("/add_reaction") +@rate_limit_per_user("50/minute") async def add_reaction( request: ReactionRequest, current_user: User = Depends(get_current_user), @@ -761,6 +766,7 @@ async def add_reaction( @router.post("/dm/add_reaction") +@rate_limit_per_user("50/minute") async def add_dm_reaction( request: DMReactionRequest, current_user: User = Depends(get_current_user), diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 071432c..6b07fea 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -7,6 +7,13 @@ 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.user_agent_blocklist import ( + add_to_blocklist as add_ua_to_blocklist, + get_blocklist as get_ua_blocklist, + get_static_blocklist as get_ua_static_blocklist, + get_external_blocklist as get_ua_external_blocklist, + remove_from_blocklist as remove_ua_from_blocklist, +) class BlocklistUpdateRequest(BaseModel): @@ -58,3 +65,45 @@ def delete_from_blocklist( ) return {"removed": removed, "words": updated} + +@router.get("/user-agent-blocklist") +def list_user_agent_blocklist(current_user: User = Depends(get_current_user)): + _ensure_owner(current_user) + return { + "patterns": get_ua_blocklist(), + "static": get_ua_static_blocklist(), + "external": get_ua_external_blocklist(), + } + + +@router.post("/user-agent-blocklist") +def append_user_agent_blocklist( + request: BlocklistUpdateRequest, + current_user: User = Depends(get_current_user) +): + _ensure_owner(current_user) + added, updated = add_ua_to_blocklist(request.words) + log_security( + "user_agent_blocklist_add", + actor=current_user.username, + actor_id=current_user.id, + added=added, + ) + return {"added": added, "patterns": updated} + + +@router.delete("/user-agent-blocklist") +def delete_from_user_agent_blocklist( + request: BlocklistUpdateRequest, + current_user: User = Depends(get_current_user) +): + _ensure_owner(current_user) + removed, updated = remove_ua_from_blocklist(request.words) + log_security( + "user_agent_blocklist_remove", + actor=current_user.username, + actor_id=current_user.id, + removed=removed, + ) + return {"removed": removed, "patterns": updated} + diff --git a/backend/routes/profile.py b/backend/routes/profile.py index bcf5716..f0aed96 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -16,6 +16,7 @@ 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_user router = APIRouter() @@ -39,6 +40,7 @@ PROFILE_PICTURES_DIR = Path("data/uploads/pfp") os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) @router.post("/upload-profile-picture") +@rate_limit_per_user("10/minute") async def upload_profile_picture( profile_picture: UploadFile = File(...), current_user: User = Depends(get_current_user), @@ -163,6 +165,7 @@ async def list_users( } @router.put("/user/profile") +@rate_limit_per_user("10/minute") async def update_user_profile( request: UpdateProfileRequest, current_user: User = Depends(get_current_user), @@ -239,6 +242,7 @@ async def update_user_profile( @router.put("/user/bio") +@rate_limit_per_user("10/minute") async def update_user_bio( request: UpdateBioRequest, current_user: User = Depends(get_current_user), diff --git a/backend/security/audit.py b/backend/security/audit.py index 52bad60..f848f23 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -189,6 +189,34 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]: total = len(fields.get("words") or []) lines.append(f"Total entries: {total}") return lines + if action == "blocked_user_agent": + action_type = fields.get("action", "access") + lines = [f"Blocked user agent attempted {action_type}"] + if fields.get("username"): + lines.append(f"Username: {fields['username']}") + if fields.get("user_agent"): + lines.append(f"User agent: {fields['user_agent']}") + if fields.get("ip"): + ip_raw = fields["ip"] + ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw + lines.append(f"IP: {ip_display}") + return lines + if action == "user_agent_blocklist_add": + added = fields.get("added") or [] + lines = [f"User agent blocklist updated by {_format_actor(fields, 'actor')}"] + if added: + lines.append(f"Added patterns: {', '.join(added)}") + total = len(fields.get("patterns") or []) + lines.append(f"Total patterns: {total}") + return lines + if action == "user_agent_blocklist_remove": + removed = fields.get("removed") or [] + lines = [f"User agent blocklist cleaned by {_format_actor(fields, 'actor')}"] + if removed: + lines.append(f"Removed patterns: {', '.join(removed)}") + total = len(fields.get("patterns") or []) + lines.append(f"Total patterns: {total}") + return lines return [f"{action.replace('_', ' ').capitalize()}"] + [ f"{key.replace('_', ' ').capitalize()}: {value}" for key, value in fields.items() diff --git a/backend/security/rate_limit.py b/backend/security/rate_limit.py new file mode 100644 index 0000000..24346d7 --- /dev/null +++ b/backend/security/rate_limit.py @@ -0,0 +1,46 @@ +from __future__ import annotations + +from typing import Callable +from fastapi import Request +from slowapi import Limiter +from slowapi.util import get_remote_address +from slowapi.errors import RateLimitExceeded + +from utils import get_client_ip + +# Initialize limiter with IP-based key function +limiter = Limiter( + key_func=lambda request: get_client_ip(request) or get_remote_address(request), + default_limits=["1000/hour"], # Global default limit + storage_uri="memory://", # In-memory storage (can be changed to Redis later) +) + + +def get_user_id_key(request: Request) -> str: + """Get rate limit key based on authenticated user ID.""" + user = getattr(getattr(request, "state", None), "current_user", None) + if user and hasattr(user, "id"): + return f"user:{user.id}" + # Fallback to IP if not authenticated + return get_client_ip(request) or get_remote_address(request) + + +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) + + +# Rate limit decorators for different endpoint types +def rate_limit_per_ip(limit: str) -> Callable: + """Rate limit based on IP address.""" + return limiter.limit(limit, key_func=get_ip_key) + + +def rate_limit_per_user(limit: str) -> Callable: + """Rate limit based on authenticated user ID, fallback to IP. + + Note: The user must be authenticated (get_current_user dependency must run first). + The user will be available in request.state.current_user after authentication. + """ + return limiter.limit(limit, key_func=get_user_id_key) + diff --git a/backend/security/user_agent_blocklist.py b/backend/security/user_agent_blocklist.py new file mode 100644 index 0000000..692c5e8 --- /dev/null +++ b/backend/security/user_agent_blocklist.py @@ -0,0 +1,245 @@ +from __future__ import annotations + +import json +import re +from pathlib import Path +from threading import RLock +from typing import Iterable, List, Set +from user_agents import parse as parse_ua + +BLOCKLIST_PATH = Path("data/user_agent_blocklist.json") +BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) + +# Hardcoded list of known bot/scraper user agents +_STATIC_BLOCKED_AGENTS: Set[str] = { + "python-requests", + "python requests", + "requests", + "curl", + "wget", + "httpie", + "go-http-client", + "java/", + "okhttp", + "apache-httpclient", + "scrapy", + "mechanize", + "beautifulsoup", + "urllib", + "httpx", + "aiohttp", + "postman", + "insomnia", + "postmanruntime", + "restclient", + "http", + "bot", + "crawler", + "spider", + "scraper", +} + +_blocklist_lock = RLock() +_blocklist_cache: Set[str] | None = None + + +def _normalize_pattern(pattern: str) -> str: + cleaned = re.sub(r"\s+", " ", str(pattern)).strip().lower() + return cleaned + + +def _load_blocklist() -> Set[str]: + global _blocklist_cache + with _blocklist_lock: + if _blocklist_cache is not None: + return _blocklist_cache + + # Start with static hardcoded patterns + patterns = set(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) + + # Load additional patterns from external file + if BLOCKLIST_PATH.exists(): + try: + data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) + if isinstance(data, list): + external_patterns = set(_normalize_pattern(p) for p in data if p) + patterns.update(external_patterns) + except Exception: + pass + + _blocklist_cache = patterns + return patterns + + +def _write_blocklist(external_patterns: Iterable[str]) -> None: + """Write only external patterns to the JSON file. Static patterns are not stored.""" + normalized = sorted(set(_normalize_pattern(p) for p in external_patterns if p)) + BLOCKLIST_PATH.write_text( + json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8" + ) + # Clear cache so it reloads with static + external patterns + global _blocklist_cache + with _blocklist_lock: + _blocklist_cache = None + + +def _match_pattern(text: str, pattern: str) -> bool: + normalized_text = text.lower() + normalized_pattern = pattern.lower() + + if normalized_pattern in normalized_text: + return True + + try: + regex = re.compile(normalized_pattern, re.IGNORECASE) + if regex.search(normalized_text): + return True + except re.error: + pass + + return False + + +def is_user_agent_blocked(raw_user_agent: str | None) -> bool: + if not raw_user_agent: + return False + + blocklist = _load_blocklist() + if not blocklist: + return False + + for pattern in blocklist: + if _match_pattern(raw_user_agent, pattern): + return True + + try: + ua = parse_ua(raw_user_agent) + browser_name = ua.browser.family or "" + os_name = ua.os.family or "" + + browser_pattern = browser_name.lower() if browser_name else "" + os_pattern = os_name.lower() if os_name else "" + + formatted = f"{os_name or 'Other'}, {browser_name or 'Unknown browser'}" + if ua.browser.version_string: + formatted = f"{formatted} {ua.browser.version_string}" + + for pattern in blocklist: + if _match_pattern(formatted, pattern): + return True + if browser_pattern and _match_pattern(browser_pattern, pattern): + return True + if os_pattern and _match_pattern(os_pattern, pattern): + return True + except Exception: + pass + + return False + + +def get_blocklist() -> List[str]: + """Get all blocked patterns (static + external).""" + with _blocklist_lock: + return sorted(_load_blocklist()) + + +def get_static_blocklist() -> List[str]: + """Get only the hardcoded static patterns.""" + return sorted(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) + + +def get_external_blocklist() -> List[str]: + """Get only the patterns from the external JSON file.""" + if not BLOCKLIST_PATH.exists(): + return [] + try: + data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) + if isinstance(data, list): + return sorted(_normalize_pattern(p) for p in data if p) + except Exception: + pass + return [] + + +def add_to_blocklist(patterns: Iterable[str]) -> tuple[List[str], List[str]]: + """Add patterns to the external blocklist. Static patterns cannot be modified.""" + normalized = set(_normalize_pattern(p) for p in patterns if p) + if not normalized: + return [], get_blocklist() + + with _blocklist_lock: + # Only add to external blocklist, not static + static_patterns = set(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) + + # Filter out static patterns (they're already blocked) + normalized = normalized - static_patterns + if not normalized: + return [], get_blocklist() + + # Load current external patterns + external_current = set() + if BLOCKLIST_PATH.exists(): + try: + data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) + if isinstance(data, list): + external_current = set(_normalize_pattern(p) for p in data if p) + except Exception: + pass + + added = sorted(normalized - external_current) + if not added: + return [], get_blocklist() + + updated_external = sorted(external_current | normalized) + _write_blocklist(updated_external) + + # Clear cache to reload + _blocklist_cache = None + + return added, get_blocklist() + + +def remove_from_blocklist(patterns: Iterable[str]) -> tuple[List[str], List[str]]: + """Remove patterns from the external blocklist. Static patterns cannot be removed.""" + normalized = set(_normalize_pattern(p) for p in patterns if p) + if not normalized: + return [], get_blocklist() + + with _blocklist_lock: + # Only remove from external blocklist, not static + static_patterns = set(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) + + # Filter out static patterns (cannot remove them) + normalized = normalized - static_patterns + if not normalized: + return [], get_blocklist() + + # Load current external patterns + external_current = set() + if BLOCKLIST_PATH.exists(): + try: + data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) + if isinstance(data, list): + external_current = set(_normalize_pattern(p) for p in data if p) + except Exception: + pass + + removed = sorted(pattern for pattern in normalized if pattern in external_current) + if not removed: + return [], get_blocklist() + + updated_external = sorted(external_current - normalized) + _write_blocklist(updated_external) + + # Clear cache to reload + _blocklist_cache = None + + return removed, get_blocklist() + + +def clear_blocklist_cache() -> None: + global _blocklist_cache + with _blocklist_lock: + _blocklist_cache = None + diff --git a/backend/utils.py b/backend/utils.py index 3b6da7e..2d294dd 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -40,15 +40,28 @@ def get_client_ip(request: Request) -> Optional[str]: 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: From 6a3b313f2c79cf0a61346b1c42d0950fc8de8e13 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 10 Nov 2025 20:13:23 +0300 Subject: [PATCH 09/59] Fix rate limiting --- backend/routes/account.py | 46 ++++++++++++------------- backend/routes/messaging.py | 68 +++++++++++++++++++++---------------- backend/routes/profile.py | 24 +++++++------ 3 files changed, 75 insertions(+), 63 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index 456e655..72985e0 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -68,10 +68,10 @@ def check_auth(current_user: User = Depends(get_current_user)): @router.post("/login") @rate_limit_per_ip("5/minute") -def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): - username = request.username.strip() - client_ip = get_client_ip(http) - raw_ua = http.headers.get("user-agent") +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 is_user_agent_blocked(raw_ua): log_security( @@ -89,7 +89,7 @@ def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): user = db.query(User).filter(User.username == username).first() - if not user or not verify_password(request.password.strip(), user.password_hash): + if not user or not verify_password(login_request.password.strip(), user.password_hash): log_security( "login_failed", severity="warning", @@ -125,8 +125,8 @@ def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): ) # Create device session and embed into JWT - raw_ua = http.headers.get("user-agent") - device_name = http.headers.get("x-device-name") + 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 @@ -181,13 +181,13 @@ def login(request: LoginRequest, http: Request, db: Session = Depends(get_db)): @router.post("/register") @rate_limit_per_ip("3/hour") -def register(request: RegisterRequest, http: Request, db: Session = Depends(get_db)): - username = request.username.strip() - display_name = request.display_name.strip() - password = request.password.strip() - confirm_password = request.confirm_password.strip() - client_ip = get_client_ip(http) - raw_ua = http.headers.get("user-agent") +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") if is_user_agent_blocked(raw_ua): log_security( @@ -281,8 +281,8 @@ def register(request: RegisterRequest, http: Request, db: Session = Depends(get_ db.refresh(new_user) # Create initial device session - raw_ua = http.headers.get("user-agent") - device_name = http.headers.get("x-device-name") + 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( @@ -447,22 +447,22 @@ def logout( @router.post("/change-password") @rate_limit_per_user("5/hour") def change_password( - request: ChangePasswordRequest, - http: Request, + 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: @@ -474,13 +474,13 @@ def change_password( ).update({DeviceSession.revoked: True}) db.commit() - client_ip = get_client_ip(http) + 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(request.logoutAllExceptCurrent), + logout_others=bool(password_request.logoutAllExceptCurrent), ) return {"status": "success"} diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 985492b..4658200 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -12,7 +12,7 @@ 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 +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 @@ -254,7 +254,8 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: @router.post("/send_message") @rate_limit_per_user("30/minute") async def send_message( - request: SendMessageRequest | None = None, + request: Request, + message_request: SendMessageRequest | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), # Optional multipart form support @@ -262,23 +263,26 @@ async def send_message( files: list[UploadFile] = File(default=[]), ): # If payload is provided, prefer it for multipart requests - if payload and request is None: + 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) - request = SendMessageRequest(content=content, reply_to_id=reply_to_id) + message_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: + if not message_request: + raise HTTPException(status_code=400, detail="Missing request data") + + 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") - raw_content = request.content.strip() + raw_content = message_request.content.strip() if not raw_content: raise HTTPException( @@ -299,7 +303,7 @@ async def send_message( new_message = Message( 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() ) @@ -412,6 +416,7 @@ async def get_messages(db: Session = Depends(get_db)): @router.post("/dm/send") @rate_limit_per_user("20/minute") async def dm_send( + request: Request, payload: dict | None = None, current_user: User = Depends(get_current_user), db: Session = Depends(get_db), @@ -624,8 +629,9 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d @router.put("/edit_message/{message_id}") @rate_limit_per_user("20/minute") async def edit_message( + request: Request, message_id: int, - request: EditMessageRequest, + edit_request: EditMessageRequest, current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): @@ -635,7 +641,7 @@ async def edit_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") - raw_content = request.content.strip() + raw_content = edit_request.content.strip() if not raw_content: raise HTTPException(status_code=400, detail="Message content cannot be empty") @@ -700,20 +706,21 @@ async def delete_message( @router.post("/add_reaction") @rate_limit_per_user("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: @@ -723,9 +730,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" @@ -742,8 +749,8 @@ async def add_reaction( 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, @@ -755,11 +762,11 @@ async def add_reaction( log_public_chat( "reaction_update", - message_id=request.message_id, + message_id=reaction_request.message_id, user_id=current_user.id, username=current_user.username, action=action, - emoji=request.emoji, + emoji=reaction_request.emoji, ) return {"status": "success", "action": action, "reactions": message_data["reactions"]} @@ -768,12 +775,13 @@ async def add_reaction( @router.post("/dm/add_reaction") @rate_limit_per_user("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") @@ -783,9 +791,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: @@ -795,9 +803,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" @@ -814,8 +822,8 @@ async def add_dm_reaction( 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, @@ -827,11 +835,11 @@ async def add_dm_reaction( log_dm( "reaction_update", - dm_envelope_id=request.dm_envelope_id, + dm_envelope_id=reaction_request.dm_envelope_id, user_id=current_user.id, username=current_user.username, action=action, - emoji=request.emoji, + emoji=reaction_request.emoji, ) return {"status": "success", "action": action, "reactions": envelope_data["reactions"]} diff --git a/backend/routes/profile.py b/backend/routes/profile.py index f0aed96..9dbe82b 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -7,6 +7,7 @@ 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 @@ -42,6 +43,7 @@ os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) @router.post("/upload-profile-picture") @rate_limit_per_user("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) @@ -167,7 +169,8 @@ async def list_users( @router.put("/user/profile") @rate_limit_per_user("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) ): @@ -177,8 +180,8 @@ 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, @@ -199,8 +202,8 @@ 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, @@ -216,8 +219,8 @@ async def update_user_profile( 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") @@ -244,17 +247,18 @@ async def update_user_profile( @router.put("/user/bio") @rate_limit_per_user("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 { From 7c1a8acca751b6e34d172c62179392a368c0ea6e Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 10 Nov 2025 20:17:42 +0300 Subject: [PATCH 10/59] Update deploy.yml Signed-off-by: denis0001-dev --- .github/workflows/deploy.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index b700cff..68c6570 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -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 From f83dcafbf7b5239fb93891c312d199ff24f3f8f7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 10 Nov 2025 21:50:17 +0300 Subject: [PATCH 11/59] Cache user similarity to reduce requests --- frontend/src/core/api/profileApi.ts | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/frontend/src/core/api/profileApi.ts b/frontend/src/core/api/profileApi.ts index f16f03b..5a25935 100644 --- a/frontend/src/core/api/profileApi.ts +++ b/frontend/src/core/api/profileApi.ts @@ -171,22 +171,40 @@ 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) { - return await response.json(); + result = await response.json(); } - return null; + // Cache the result (even if null/error) + similarityCache.set(userId, result); + return result; } catch (error) { console.error('Error checking user similarity:', error); - return null; + const result: null = null; + // Cache null result to avoid retrying on errors + similarityCache.set(userId, result); + return result; } } From 22d667093dba5a5996c4e92d319917bc25994524 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 10 Nov 2025 22:09:09 +0300 Subject: [PATCH 12/59] Fix backend error --- backend/routes/account.py | 4 +-- backend/routes/messaging.py | 67 +++++++++++++++++++++++-------------- backend/security/audit.py | 2 +- 3 files changed, 44 insertions(+), 29 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index 72985e0..e7b101a 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -80,7 +80,7 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g username=username, ip=client_ip, user_agent=raw_ua or "Unknown", - action="login", + action_type="login", ) raise HTTPException( status_code=403, @@ -196,7 +196,7 @@ def register(request: Request, register_request: RegisterRequest, db: Session = username=username, ip=client_ip, user_agent=raw_ua or "Unknown", - action="registration", + action_type="registration", ) raise HTTPException( status_code=403, diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 4658200..0737d0d 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -251,31 +251,17 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: ] } -@router.post("/send_message") -@rate_limit_per_user("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") +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 == message_request.reply_to_id).first() @@ -399,6 +385,34 @@ async def send_message( return {"status": "success", "message": message_payload} +@router.post("/send_message") +@rate_limit_per_user("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) + + @router.get("/get_messages") async def get_messages(db: Session = Depends(get_db)): messages = db.query(Message).order_by(Message.timestamp.asc()).all() @@ -963,9 +977,10 @@ class MessaggingSocketManager: raise HTTPException(401) self.user_by_ws[websocket] = current_user.id - request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) + message_request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - response = await send_message(request, current_user, db, None, []) + # Call internal function directly (rate limiting is handled at infrastructure level via Caddy) + response = await _send_message_internal(message_request, current_user, db, []) await self.broadcast({ "type": "newMessage", "data": response["message"] diff --git a/backend/security/audit.py b/backend/security/audit.py index f848f23..cf30fdc 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -190,7 +190,7 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]: lines.append(f"Total entries: {total}") return lines if action == "blocked_user_agent": - action_type = fields.get("action", "access") + action_type = fields.get("action_type", "access") lines = [f"Blocked user agent attempted {action_type}"] if fields.get("username"): lines.append(f"Username: {fields['username']}") From 019414608f401c03f7fb5780bbf597e963bb1efb Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 12 Nov 2025 16:31:55 +0300 Subject: [PATCH 13/59] Fix rate limiting --- backend/routes/account.py | 13 ++++++----- backend/routes/messaging.py | 24 +++++++++++--------- backend/routes/profile.py | 8 +++---- backend/security/rate_limit.py | 41 +++++++++------------------------- 4 files changed, 37 insertions(+), 49 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index e7b101a..8042c20 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -18,7 +18,7 @@ import os from security.audit import log_security from security.profanity import contains_profanity from security.user_agent_blocklist import is_user_agent_blocked -from security.rate_limit import rate_limit_per_ip, rate_limit_per_user +from security.rate_limit import rate_limit_per_ip router = APIRouter() _FAILED_ATTEMPT_WINDOW_SECONDS = 300 @@ -445,7 +445,7 @@ def logout( @router.post("/change-password") -@rate_limit_per_user("5/hour") +@rate_limit_per_ip("5/hour") def change_password( request: Request, password_request: ChangePasswordRequest, @@ -487,7 +487,8 @@ def change_password( @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": [ @@ -497,13 +498,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": []} diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 0737d0d..2af4a9a 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -27,7 +27,7 @@ import json from better_profanity import profanity as _bp from security.audit import log_access, log_dm, log_public_chat, log_security from security.profanity import censor_text -from security.rate_limit import rate_limit_per_user +from security.rate_limit import rate_limit_per_ip router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -386,7 +386,7 @@ async def _send_message_internal( @router.post("/send_message") -@rate_limit_per_user("30/minute") +@rate_limit_per_ip("30/minute") async def send_message( request: Request, message_request: SendMessageRequest | None = None, @@ -414,7 +414,8 @@ async def send_message( @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 = [] @@ -428,7 +429,7 @@ async def get_messages(db: Session = Depends(get_db)): @router.post("/dm/send") -@rate_limit_per_user("20/minute") +@rate_limit_per_ip("20/minute") async def dm_send( request: Request, payload: dict | None = None, @@ -578,7 +579,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) @@ -586,7 +588,8 @@ 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)): return convert_envelopes( db.query(DMEnvelope) .filter( @@ -599,7 +602,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) @@ -641,7 +645,7 @@ async def get_dm_conversations(current_user: User = Depends(get_current_user), d @router.put("/edit_message/{message_id}") -@rate_limit_per_user("20/minute") +@rate_limit_per_ip("20/minute") async def edit_message( request: Request, message_id: int, @@ -718,7 +722,7 @@ async def delete_message( @router.post("/add_reaction") -@rate_limit_per_user("50/minute") +@rate_limit_per_ip("50/minute") async def add_reaction( request: Request, reaction_request: ReactionRequest, @@ -787,7 +791,7 @@ async def add_reaction( @router.post("/dm/add_reaction") -@rate_limit_per_user("50/minute") +@rate_limit_per_ip("50/minute") async def add_dm_reaction( request: Request, reaction_request: DMReactionRequest, diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 9dbe82b..6e1cb52 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -17,7 +17,7 @@ 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_user +from security.rate_limit import rate_limit_per_ip router = APIRouter() @@ -41,7 +41,7 @@ PROFILE_PICTURES_DIR = Path("data/uploads/pfp") os.makedirs(PROFILE_PICTURES_DIR, exist_ok=True) @router.post("/upload-profile-picture") -@rate_limit_per_user("10/minute") +@rate_limit_per_ip("10/minute") async def upload_profile_picture( request: Request, profile_picture: UploadFile = File(...), @@ -167,7 +167,7 @@ async def list_users( } @router.put("/user/profile") -@rate_limit_per_user("10/minute") +@rate_limit_per_ip("10/minute") async def update_user_profile( request: Request, update_request: UpdateProfileRequest, @@ -245,7 +245,7 @@ async def update_user_profile( @router.put("/user/bio") -@rate_limit_per_user("10/minute") +@rate_limit_per_ip("10/minute") async def update_user_bio( request: Request, bio_request: UpdateBioRequest, diff --git a/backend/security/rate_limit.py b/backend/security/rate_limit.py index 24346d7..08e5f6d 100644 --- a/backend/security/rate_limit.py +++ b/backend/security/rate_limit.py @@ -4,43 +4,24 @@ from typing import Callable from fastapi import Request from slowapi import Limiter from slowapi.util import get_remote_address -from slowapi.errors import RateLimitExceeded from utils import get_client_ip -# Initialize limiter with IP-based key function -limiter = Limiter( - key_func=lambda request: get_client_ip(request) or get_remote_address(request), - default_limits=["1000/hour"], # Global default limit - storage_uri="memory://", # In-memory storage (can be changed to Redis later) -) - - -def get_user_id_key(request: Request) -> str: - """Get rate limit key based on authenticated user ID.""" - user = getattr(getattr(request, "state", None), "current_user", None) - if user and hasattr(user, "id"): - return f"user:{user.id}" - # Fallback to IP if not authenticated - return get_client_ip(request) or get_remote_address(request) - - 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. +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 decorators for different endpoint types + +# 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 rate_limit_per_user(limit: str) -> Callable: - """Rate limit based on authenticated user ID, fallback to IP. - - Note: The user must be authenticated (get_current_user dependency must run first). - The user will be available in request.state.current_user after authentication. - """ - return limiter.limit(limit, key_func=get_user_id_key) - + return limiter.limit(limit, key_func=get_ip_key) \ No newline at end of file From 68f3f805b24dbad34bd5b436d18278b6649a433b Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 12 Nov 2025 18:33:33 +0300 Subject: [PATCH 14/59] Remove user agent checks --- backend/admin_cli.py | 66 ------ backend/routes/account.py | 29 --- backend/routes/moderation.py | 47 ----- backend/security/audit.py | 28 --- backend/security/user_agent_blocklist.py | 245 ----------------------- 5 files changed, 415 deletions(-) delete mode 100644 backend/security/user_agent_blocklist.py diff --git a/backend/admin_cli.py b/backend/admin_cli.py index 0f612e2..6afd4f8 100644 --- a/backend/admin_cli.py +++ b/backend/admin_cli.py @@ -276,63 +276,6 @@ class AdminCLI: table.add_row(entry) self.console.print(table) - def cmd_block_user_agent(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: block-user-agent [additional patterns...]") - self._require_auth() - patterns = args - response = self._request("POST", "moderation/user-agent-blocklist", json={"words": patterns}) - data = response.json() - added = data.get("added", []) - current = data.get("patterns", []) - if added: - self.console.print(f"[bold green]Added {len(added)} pattern{'s' if len(added) != 1 else ''} to user agent blocklist.[/]") - else: - self.console.print("[yellow]No new patterns added.[/]") - self.console.print(f"Blocklist size: {len(current)}") - - def cmd_unblock_user_agent(self, args: List[str]) -> None: - if not args: - raise CLIError("Usage: unblock-user-agent [additional patterns...]") - self._require_auth() - response = self._request("DELETE", "moderation/user-agent-blocklist", json={"words": args}) - data = response.json() - removed = data.get("removed", []) - current = data.get("patterns", []) - if removed: - self.console.print(f"[bold green]Removed {len(removed)} pattern{'s' if len(removed) != 1 else ''} from user agent blocklist.[/]") - else: - self.console.print("[yellow]No matching patterns removed.[/]") - self.console.print(f"Blocklist size: {len(current)}") - - def cmd_list_user_agent_blocklist(self) -> None: - self._require_auth() - response = self._request("GET", "moderation/user-agent-blocklist") - data = response.json() - static = data.get("static", []) - external = data.get("external", []) - - if not static and not external: - self.console.print("[cyan]User agent blocklist is empty.[/]") - return - - if static: - table_static = Table(title="Static Blocked User Agent Patterns", show_lines=True) - table_static.add_column("Pattern", style="yellow") - for entry in static: - table_static.add_row(entry) - self.console.print(table_static) - - if external: - table_external = Table(title="External Blocked User Agent Patterns", show_lines=True) - table_external.add_column("Pattern", style="cyan") - for entry in external: - table_external.add_row(entry) - self.console.print(table_external) - - if not external: - self.console.print("[dim]No external patterns. Use 'block-user-agent' to add patterns.[/]") - def cmd_help(self) -> None: cmds = { "login [username]": "Authenticate as owner/admin.", @@ -344,9 +287,6 @@ class AdminCLI: "block-word ": "Add words/phrases to chat filter.", "unblock-word ": "Remove words/phrases from filter.", "blocklist": "Show current blocklist.", - "block-user-agent ": "Add user agent patterns to blocklist.", - "unblock-user-agent ": "Remove user agent patterns from blocklist.", - "user-agent-blocklist": "Show current user agent blocklist.", "list": "List all users.", "user ": "Show detailed user information.", "whoami": "Display current session context.", @@ -407,12 +347,6 @@ class AdminCLI: self.cmd_unblock_word(args) elif command == "blocklist": self.cmd_list_blocklist() - elif command == "block-user-agent": - self.cmd_block_user_agent(args) - elif command == "unblock-user-agent": - self.cmd_unblock_user_agent(args) - elif command == "user-agent-blocklist": - self.cmd_list_user_agent_blocklist() elif command == "verify": self.cmd_verify(args) elif command == "unverify": diff --git a/backend/routes/account.py b/backend/routes/account.py index 8042c20..02fd8d3 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -17,7 +17,6 @@ import os from security.audit import log_security from security.profanity import contains_profanity -from security.user_agent_blocklist import is_user_agent_blocked from security.rate_limit import rate_limit_per_ip router = APIRouter() @@ -73,20 +72,6 @@ def login(request: Request, login_request: LoginRequest, db: Session = Depends(g client_ip = get_client_ip(request) raw_ua = request.headers.get("user-agent") - if is_user_agent_blocked(raw_ua): - log_security( - "blocked_user_agent", - severity="warning", - username=username, - ip=client_ip, - user_agent=raw_ua or "Unknown", - action_type="login", - ) - raise HTTPException( - status_code=403, - detail="Доступ запрещён" - ) - user = db.query(User).filter(User.username == username).first() if not user or not verify_password(login_request.password.strip(), user.password_hash): @@ -189,20 +174,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session = client_ip = get_client_ip(request) raw_ua = request.headers.get("user-agent") - if is_user_agent_blocked(raw_ua): - log_security( - "blocked_user_agent", - severity="warning", - username=username, - ip=client_ip, - user_agent=raw_ua or "Unknown", - action_type="registration", - ) - raise HTTPException( - status_code=403, - detail="Доступ запрещён" - ) - # Determine if owner already exists owner_exists = db.query(User).filter(User.username == OWNER_USERNAME).first() is not None diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 6b07fea..7eee746 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -7,13 +7,6 @@ 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.user_agent_blocklist import ( - add_to_blocklist as add_ua_to_blocklist, - get_blocklist as get_ua_blocklist, - get_static_blocklist as get_ua_static_blocklist, - get_external_blocklist as get_ua_external_blocklist, - remove_from_blocklist as remove_ua_from_blocklist, -) class BlocklistUpdateRequest(BaseModel): @@ -66,44 +59,4 @@ def delete_from_blocklist( return {"removed": removed, "words": updated} -@router.get("/user-agent-blocklist") -def list_user_agent_blocklist(current_user: User = Depends(get_current_user)): - _ensure_owner(current_user) - return { - "patterns": get_ua_blocklist(), - "static": get_ua_static_blocklist(), - "external": get_ua_external_blocklist(), - } - - -@router.post("/user-agent-blocklist") -def append_user_agent_blocklist( - request: BlocklistUpdateRequest, - current_user: User = Depends(get_current_user) -): - _ensure_owner(current_user) - added, updated = add_ua_to_blocklist(request.words) - log_security( - "user_agent_blocklist_add", - actor=current_user.username, - actor_id=current_user.id, - added=added, - ) - return {"added": added, "patterns": updated} - - -@router.delete("/user-agent-blocklist") -def delete_from_user_agent_blocklist( - request: BlocklistUpdateRequest, - current_user: User = Depends(get_current_user) -): - _ensure_owner(current_user) - removed, updated = remove_ua_from_blocklist(request.words) - log_security( - "user_agent_blocklist_remove", - actor=current_user.username, - actor_id=current_user.id, - removed=removed, - ) - return {"removed": removed, "patterns": updated} diff --git a/backend/security/audit.py b/backend/security/audit.py index cf30fdc..52bad60 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -189,34 +189,6 @@ def _render_security(action: str, fields: Dict[str, Any]) -> List[str]: total = len(fields.get("words") or []) lines.append(f"Total entries: {total}") return lines - if action == "blocked_user_agent": - action_type = fields.get("action_type", "access") - lines = [f"Blocked user agent attempted {action_type}"] - if fields.get("username"): - lines.append(f"Username: {fields['username']}") - if fields.get("user_agent"): - lines.append(f"User agent: {fields['user_agent']}") - if fields.get("ip"): - ip_raw = fields["ip"] - ip_display = "localhost" if ip_raw in {"127.0.0.1", "::1"} else ip_raw - lines.append(f"IP: {ip_display}") - return lines - if action == "user_agent_blocklist_add": - added = fields.get("added") or [] - lines = [f"User agent blocklist updated by {_format_actor(fields, 'actor')}"] - if added: - lines.append(f"Added patterns: {', '.join(added)}") - total = len(fields.get("patterns") or []) - lines.append(f"Total patterns: {total}") - return lines - if action == "user_agent_blocklist_remove": - removed = fields.get("removed") or [] - lines = [f"User agent blocklist cleaned by {_format_actor(fields, 'actor')}"] - if removed: - lines.append(f"Removed patterns: {', '.join(removed)}") - total = len(fields.get("patterns") or []) - lines.append(f"Total patterns: {total}") - return lines return [f"{action.replace('_', ' ').capitalize()}"] + [ f"{key.replace('_', ' ').capitalize()}: {value}" for key, value in fields.items() diff --git a/backend/security/user_agent_blocklist.py b/backend/security/user_agent_blocklist.py deleted file mode 100644 index 692c5e8..0000000 --- a/backend/security/user_agent_blocklist.py +++ /dev/null @@ -1,245 +0,0 @@ -from __future__ import annotations - -import json -import re -from pathlib import Path -from threading import RLock -from typing import Iterable, List, Set -from user_agents import parse as parse_ua - -BLOCKLIST_PATH = Path("data/user_agent_blocklist.json") -BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) - -# Hardcoded list of known bot/scraper user agents -_STATIC_BLOCKED_AGENTS: Set[str] = { - "python-requests", - "python requests", - "requests", - "curl", - "wget", - "httpie", - "go-http-client", - "java/", - "okhttp", - "apache-httpclient", - "scrapy", - "mechanize", - "beautifulsoup", - "urllib", - "httpx", - "aiohttp", - "postman", - "insomnia", - "postmanruntime", - "restclient", - "http", - "bot", - "crawler", - "spider", - "scraper", -} - -_blocklist_lock = RLock() -_blocklist_cache: Set[str] | None = None - - -def _normalize_pattern(pattern: str) -> str: - cleaned = re.sub(r"\s+", " ", str(pattern)).strip().lower() - return cleaned - - -def _load_blocklist() -> Set[str]: - global _blocklist_cache - with _blocklist_lock: - if _blocklist_cache is not None: - return _blocklist_cache - - # Start with static hardcoded patterns - patterns = set(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) - - # Load additional patterns from external file - if BLOCKLIST_PATH.exists(): - try: - data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) - if isinstance(data, list): - external_patterns = set(_normalize_pattern(p) for p in data if p) - patterns.update(external_patterns) - except Exception: - pass - - _blocklist_cache = patterns - return patterns - - -def _write_blocklist(external_patterns: Iterable[str]) -> None: - """Write only external patterns to the JSON file. Static patterns are not stored.""" - normalized = sorted(set(_normalize_pattern(p) for p in external_patterns if p)) - BLOCKLIST_PATH.write_text( - json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8" - ) - # Clear cache so it reloads with static + external patterns - global _blocklist_cache - with _blocklist_lock: - _blocklist_cache = None - - -def _match_pattern(text: str, pattern: str) -> bool: - normalized_text = text.lower() - normalized_pattern = pattern.lower() - - if normalized_pattern in normalized_text: - return True - - try: - regex = re.compile(normalized_pattern, re.IGNORECASE) - if regex.search(normalized_text): - return True - except re.error: - pass - - return False - - -def is_user_agent_blocked(raw_user_agent: str | None) -> bool: - if not raw_user_agent: - return False - - blocklist = _load_blocklist() - if not blocklist: - return False - - for pattern in blocklist: - if _match_pattern(raw_user_agent, pattern): - return True - - try: - ua = parse_ua(raw_user_agent) - browser_name = ua.browser.family or "" - os_name = ua.os.family or "" - - browser_pattern = browser_name.lower() if browser_name else "" - os_pattern = os_name.lower() if os_name else "" - - formatted = f"{os_name or 'Other'}, {browser_name or 'Unknown browser'}" - if ua.browser.version_string: - formatted = f"{formatted} {ua.browser.version_string}" - - for pattern in blocklist: - if _match_pattern(formatted, pattern): - return True - if browser_pattern and _match_pattern(browser_pattern, pattern): - return True - if os_pattern and _match_pattern(os_pattern, pattern): - return True - except Exception: - pass - - return False - - -def get_blocklist() -> List[str]: - """Get all blocked patterns (static + external).""" - with _blocklist_lock: - return sorted(_load_blocklist()) - - -def get_static_blocklist() -> List[str]: - """Get only the hardcoded static patterns.""" - return sorted(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) - - -def get_external_blocklist() -> List[str]: - """Get only the patterns from the external JSON file.""" - if not BLOCKLIST_PATH.exists(): - return [] - try: - data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) - if isinstance(data, list): - return sorted(_normalize_pattern(p) for p in data if p) - except Exception: - pass - return [] - - -def add_to_blocklist(patterns: Iterable[str]) -> tuple[List[str], List[str]]: - """Add patterns to the external blocklist. Static patterns cannot be modified.""" - normalized = set(_normalize_pattern(p) for p in patterns if p) - if not normalized: - return [], get_blocklist() - - with _blocklist_lock: - # Only add to external blocklist, not static - static_patterns = set(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) - - # Filter out static patterns (they're already blocked) - normalized = normalized - static_patterns - if not normalized: - return [], get_blocklist() - - # Load current external patterns - external_current = set() - if BLOCKLIST_PATH.exists(): - try: - data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) - if isinstance(data, list): - external_current = set(_normalize_pattern(p) for p in data if p) - except Exception: - pass - - added = sorted(normalized - external_current) - if not added: - return [], get_blocklist() - - updated_external = sorted(external_current | normalized) - _write_blocklist(updated_external) - - # Clear cache to reload - _blocklist_cache = None - - return added, get_blocklist() - - -def remove_from_blocklist(patterns: Iterable[str]) -> tuple[List[str], List[str]]: - """Remove patterns from the external blocklist. Static patterns cannot be removed.""" - normalized = set(_normalize_pattern(p) for p in patterns if p) - if not normalized: - return [], get_blocklist() - - with _blocklist_lock: - # Only remove from external blocklist, not static - static_patterns = set(_normalize_pattern(p) for p in _STATIC_BLOCKED_AGENTS) - - # Filter out static patterns (cannot remove them) - normalized = normalized - static_patterns - if not normalized: - return [], get_blocklist() - - # Load current external patterns - external_current = set() - if BLOCKLIST_PATH.exists(): - try: - data = json.loads(BLOCKLIST_PATH.read_text(encoding="utf-8")) - if isinstance(data, list): - external_current = set(_normalize_pattern(p) for p in data if p) - except Exception: - pass - - removed = sorted(pattern for pattern in normalized if pattern in external_current) - if not removed: - return [], get_blocklist() - - updated_external = sorted(external_current - normalized) - _write_blocklist(updated_external) - - # Clear cache to reload - _blocklist_cache = None - - return removed, get_blocklist() - - -def clear_blocklist_cache() -> None: - global _blocklist_cache - with _blocklist_lock: - _blocklist_cache = None - From 4f79e03fd011b73cd83712044a85203ad756632f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 12 Nov 2025 20:25:53 +0300 Subject: [PATCH 15/59] Fix #11 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) From 59472ea1d764df9746f7b40ced2714a8a089bb8f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 7 Nov 2025 11:44:27 +0300 Subject: [PATCH 16/59] Redesign the UI --- .cursor/rules/general.mdc | 5 +- frontend/src/App.tsx | 68 +++- frontend/src/css/_components.scss | 75 ---- frontend/src/css/_material.scss | 87 ++--- frontend/src/pages/auth/Auth.tsx | 117 +++++- frontend/src/pages/auth/AuthPage.tsx | 164 +++++++++ frontend/src/pages/auth/AuthTextField.tsx | 138 ++++++++ frontend/src/pages/auth/LoginForm.tsx | 220 ++++++++++++ frontend/src/pages/auth/LoginPage.tsx | 155 -------- frontend/src/pages/auth/RegisterForm.tsx | 249 +++++++++++++ frontend/src/pages/auth/RegisterPage.tsx | 173 --------- frontend/src/pages/auth/auth.module.scss | 333 ++++++++++++++++-- .../src/pages/chat/css/ChatInput.module.scss | 2 +- .../src/pages/chat/css/Message.module.scss | 4 +- .../src/pages/chat/css/callWindow.module.scss | 4 - .../src/pages/chat/css/layout.module.scss | 7 +- .../src/pages/chat/css/left-panel.module.scss | 6 +- .../pages/chat/css/profile-dialog.module.scss | 3 + frontend/src/pages/home/home.module.scss | 10 +- .../src/pages/not-found/not-found.module.scss | 6 +- frontend/src/utils/material.tsx | 2 +- 21 files changed, 1306 insertions(+), 522 deletions(-) create mode 100644 frontend/src/pages/auth/AuthPage.tsx create mode 100644 frontend/src/pages/auth/AuthTextField.tsx create mode 100644 frontend/src/pages/auth/LoginForm.tsx delete mode 100644 frontend/src/pages/auth/LoginPage.tsx create mode 100644 frontend/src/pages/auth/RegisterForm.tsx delete mode 100644 frontend/src/pages/auth/RegisterPage.tsx 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/frontend/src/App.tsx b/frontend/src/App.tsx index 7cb4c96..8cb4602 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,23 +1,25 @@ -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 { 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 { 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,6 +68,54 @@ function SmartCatchAll() { } } +function AnimatedRoutes() { + const location = useLocation(); + const prevPathnameRef = useRef(location.pathname); + + 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 { restoreUserFromStorage, user } = useAppState(); const [authReady, setAuthReady] = useState(false); @@ -80,11 +130,7 @@ export default function App() {
- - {routeConfig.map((route, index) => ( - - ))} - +
{user.isSuspended && ( -
+
+ {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..ae5f888 --- /dev/null +++ b/frontend/src/pages/auth/AuthPage.tsx @@ -0,0 +1,164 @@ +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); + + 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(() => { + // 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 ( + +
+ + {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..be6b091 --- /dev/null +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -0,0 +1,220 @@ +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 { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; +import { API_BASE_URL } from "@/core/config"; +import { useAppState } from "@/pages/chat/state"; +import { MaterialButton } from "@/utils/material"; +import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +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"; + +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 = useAppState(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 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(); + setUser(data.token, data.user); + + try { + await ensureKeysOnLogin(password, data.token); + } catch (e) { + console.error("Key setup 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); + } + } else { + const data: ErrorResponse = await response.json(); + + 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; + } + + showAlert("danger", data.message || "Неверное имя пользователя или пароль"); + } + } catch (error) { + showAlert("danger", "Ошибка соединения с сервером"); + } 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..b3e5c02 --- /dev/null +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -0,0 +1,249 @@ +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 { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; +import { API_BASE_URL } from "@/core/config"; +import { useAppState } from "@/pages/chat/state"; +import { MaterialButton, MaterialIconButton } from "@/utils/material"; +import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +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 = useAppState(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 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(); + setUser(data.token, data.user); + + 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", "Ошибка соединения с сервером"); + } 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..cdd1f52 100644 --- a/frontend/src/pages/chat/css/ChatInput.module.scss +++ b/frontend/src/pages/chat/css/ChatInput.module.scss @@ -116,7 +116,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..f1cfe50 100644 --- a/frontend/src/pages/chat/css/Message.module.scss +++ b/frontend/src/pages/chat/css/Message.module.scss @@ -210,7 +210,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; } @@ -232,7 +232,7 @@ flex-direction: row-reverse; .messageInner { - background: linear-gradient(135deg, color.adjust($color-dark-primary, $lightness: 8%), color.adjust($color-dark-primary-container, $lightness: 5%)); + background: linear-gradient(135deg, #9333EA, #6366F1, #3B82F6); color: $color-dark-on-primary; border-top-right-radius: 5px; box-shadow: 0 0 20px rgba($color-dark-primary, 0.4); 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..098c72e 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -30,11 +30,11 @@ 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 { 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/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/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; From af530abf6cb169f78405da46bc0aa8873184fd2c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sat, 8 Nov 2025 18:30:31 +0300 Subject: [PATCH 17/59] Add new logo --- frontend/src/images/logo.png | Bin 22381 -> 0 bytes frontend/src/images/logo.svg | 2297 +++++++++++++++++ .../src/pages/chat/css/left-panel.module.scss | 8 + .../src/pages/chat/ui/left/ChatHeader.tsx | 4 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 5 +- 5 files changed, 2310 insertions(+), 4 deletions(-) delete mode 100644 frontend/src/images/logo.png create mode 100644 frontend/src/images/logo.svg diff --git a/frontend/src/images/logo.png b/frontend/src/images/logo.png deleted file mode 100644 index 4eade181b826abfe605dd641870a7678c22272ab..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22381 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 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/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index 098c72e..0184cef 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -26,6 +26,14 @@ align-items: center; padding: 16px; + .logo { + $size: 35px; + + width: $size; + height: $size; + margin-right: 8px; + } + .productName { flex-grow: 1; font-size: 1.8rem; diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index dac200b..cfb4f14 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -5,13 +5,14 @@ import { useState } from "react"; import { useAppState } from "@/pages/chat/state"; 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() { const { profileData } = useProfile(); const { setProfileDialog, user } = useAppState(); const [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); - const handleProfileClick = () => { + function handleProfileClick() { setProfileDialog({ userId: user.currentUser?.id, username: profileData?.username || "Пользователь", @@ -27,6 +28,7 @@ export function ChatHeader() { return ( <>
+ Logo
{PRODUCT_NAME}
diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index c191875..49cbc8e 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -15,8 +15,7 @@ function BottomAppBar() { <> onSettingsOpenChange(true)} /> - -
+
+
From 370b57f2c9a3c98f03b9d03a5805500a036f18fb Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 7 Nov 2025 16:58:39 +0300 Subject: [PATCH 18/59] Simplify chats list --- .../src/pages/chat/css/left-panel.module.scss | 44 +--- frontend/src/pages/chat/ui/left/ChatTabs.tsx | 33 --- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 4 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 241 ++++++++---------- 4 files changed, 107 insertions(+), 215 deletions(-) delete mode 100644 frontend/src/pages/chat/ui/left/ChatTabs.tsx diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index 0184cef..0cf9e9c 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -74,45 +74,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 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 49cbc8e..7fdce4b 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -2,7 +2,7 @@ import { useAppState } from "@/pages/chat/state"; import { 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 styles from "@/pages/chat/css/left-panel.module.scss"; @@ -35,7 +35,7 @@ export function LeftPanel() {
- +
); diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 3a4f709..fb40dad 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useMemo } from "react"; import { useAppState } from "@/pages/chat/state"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { API_BASE_URL } from "@/core/config"; @@ -36,18 +36,17 @@ 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 { 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; @@ -58,13 +57,9 @@ export function UnifiedChatsList() { if (response.ok) { const data = await response.json(); - if (data.messages && data.messages.length > 0) { + if (data.messages?.length > 0) { const lastMessage = data.messages[data.messages.length - 1]; - - setLastMessages({ - general: lastMessage, - general2: lastMessage - }); + setLastMessages({ general: lastMessage }); } } } catch (error) { @@ -72,7 +67,6 @@ export function UnifiedChatsList() { } }, [user.authToken]); - // Load DM users when chats tab is active useEffect(() => { if (chat.activeTab === "chats") { loadUsers(); @@ -80,79 +74,56 @@ export function UnifiedChatsList() { } }, [chat.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,45 +132,33 @@ 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) { @@ -207,12 +166,11 @@ export function UnifiedChatsList() { if (!authToken) return; const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); - if (publicKey) { - dmConversation.publicKey = publicKey; - } else { + if (!publicKey) { console.error("Failed to get public key for user:", dmConversation.id); return; } + dmConversation.publicKey = publicKey; } await switchToDM({ @@ -222,26 +180,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} + + )} +
+ ); })}
); From fd5cc7457cb43c3cc103515c06e91ed417ae4e3a Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 17:31:08 +0300 Subject: [PATCH 19/59] Fix the search bar --- frontend/src/core/components/SearchBar.tsx | 62 +++++++++++++++---- .../core/components/css/searchBar.module.scss | 2 +- .../src/pages/chat/css/left-panel.module.scss | 1 + .../src/pages/chat/ui/left/ChatHeader.tsx | 4 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 20 +++--- .../src/pages/chat/ui/left/UsernameSearch.tsx | 13 +++- 6 files changed, 78 insertions(+), 24 deletions(-) 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/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/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index 0cf9e9c..375f340 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -108,6 +108,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/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index cfb4f14..67c476d 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -7,7 +7,7 @@ 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 [profilePictureUrl, setProfilePictureUrl] = useState(profileData?.profile_picture || defaultAvatar); @@ -27,7 +27,7 @@ export function ChatHeader() { return ( <> -
+
Logo
{PRODUCT_NAME}
diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index 7fdce4b..3287784 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -1,19 +1,19 @@ import { useAppState } from "@/pages/chat/state"; -import { useState } from "react"; +import { useRef, useState } from "react"; import { SettingsDialog } from "./settings/SettingsDialog"; import { UsernameSearch } from "./UsernameSearch"; 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(); return ( <> - + onSettingsOpenChange(true)} />
(null); + const headerRef = useRef(null); + const bottomAppBarRef = useRef(null); + return ( -
- +
+
- +
- +
); } diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index b27c861..9ed8dae 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -8,7 +8,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,7 +16,13 @@ interface SearchUser extends User { verified?: boolean; } -export function UsernameSearch() { +export interface UsernameSearchProps { + containerRef: React.RefObject; + headerRef?: React.RefObject; + bottomAppBarRef?: React.RefObject; +} + +export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) { const { user, switchToDM, chat } = useAppState(); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); @@ -165,6 +171,9 @@ export function UsernameSearch() { icon="arrow_back--outlined" /> ) : "search--outlined"} + containerRef={containerRef} + headerRef={headerRef} + bottomAppBarRef={bottomAppBarRef} > {isSearching && (
From d75baedf36cea6abd287fcbce601b0ea16ee1710 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 18:57:10 +0300 Subject: [PATCH 20/59] Make suspended accounts look like deleted --- backend/routes/account.py | 2 +- backend/routes/messaging.py | 8 ++++---- backend/routes/profile.py | 27 ++++++++++++++++++++++----- 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index 02fd8d3..eb90569 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -53,7 +53,7 @@ def convert_user(user: User) -> dict: "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") diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 2af4a9a..7f321f2 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -164,8 +164,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 @@ -222,8 +222,8 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: 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 diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 6e1cb52..04a38ea 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -131,7 +131,7 @@ async def get_user_profile( verified=current_user.verified, suspended=current_user.suspended or False, suspension_reason=current_user.suspension_reason, - deleted=current_user.deleted or False, + deleted=(current_user.deleted or current_user.suspended) or False, # Treat suspended as deleted ) @@ -160,7 +160,7 @@ async def list_users( 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 ).model_dump() for user in users ] @@ -282,6 +282,23 @@ async def get_user_by_username( _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, username=user.username, @@ -294,7 +311,7 @@ async def get_user_by_username( 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("/user/id/{user_id}") @@ -312,8 +329,8 @@ async def get_user_by_id( _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", From 92cc78d9ffc3412444fa9b8b6827a84e79fc60ab Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 17:58:29 +0300 Subject: [PATCH 21/59] Clean up and fix issues --- backend/dependencies.py | 5 +- backend/push_service.py | 2 +- backend/routes/account.py | 4 + backend/routes/devices.py | 3 + backend/routes/messaging.py | 38 ++- backend/routes/profile.py | 6 + backend/utils.py | 2 +- frontend/index.html | 2 +- .../push-notifications/push-notifications.ts | 3 +- .../core/push-notifications/service-worker.ts | 6 +- frontend/src/core/websocket.ts | 279 +++++++++++++----- frontend/src/css/_components.scss | 2 +- frontend/src/css/_material.scss | 5 - .../src/pages/chat/css/left-panel.module.scss | 1 + 14 files changed, 259 insertions(+), 99 deletions(-) diff --git a/backend/dependencies.py b/backend/dependencies.py index 5bdfcab..c19adee 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,8 +1,9 @@ +from datetime import datetime 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 security = HTTPBearer() diff --git a/backend/push_service.py b/backend/push_service.py index ee48ded..a06c36d 100644 --- a/backend/push_service.py +++ b/backend/push_service.py @@ -107,7 +107,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 } diff --git a/backend/routes/account.py b/backend/routes/account.py index eb90569..445930e 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -313,6 +313,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 @@ -334,6 +336,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 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 7f321f2..bd75081 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -198,7 +198,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: @@ -217,9 +217,6 @@ 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 or suspended users @@ -454,9 +451,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"], @@ -590,6 +603,17 @@ async def dm_fetch(request: Request, since: int | None = None, current_user: Use @router.get("/dm/history/{other_user_id}") @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( @@ -631,7 +655,7 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge result.append({ "user": convert_user(other_user), - "lastMessage": convert_dm_envelope(latest_message), + "lastMessage": convert_dm_envelope(db, latest_message), "unreadCount": unread_count }) @@ -833,7 +857,7 @@ async def add_dm_reaction( # Refresh envelope to get updated reactions db.refresh(envelope) - envelope_data = convert_dm_envelope(envelope) + envelope_data = convert_dm_envelope(db, envelope) # Broadcast reaction update to both participants try: diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 04a38ea..31d1794 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -275,6 +275,9 @@ 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: @@ -322,6 +325,9 @@ 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: diff --git a/backend/utils.py b/backend/utils.py index 2d294dd..660b475 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -4,7 +4,7 @@ import jwt from typing import Optional, Any import bcrypt -from constants import * +from constants import ACCESS_TOKEN_EXPIRE_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM # JWT Helper Functions def create_token(user_id: int, username: str, session_id: str) -> str: 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/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index bef62c9..a0760dd 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -3,6 +3,7 @@ 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; @@ -111,7 +112,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/websocket.ts b/frontend/src/core/websocket.ts index 435a113..d4400c1 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -44,6 +44,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,97 +72,208 @@ 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(); +function getReconnectDelay(attempt: number): number { + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt); + return Math.min(delay, MAX_RECONNECT_DELAY); +} - let listener: () => void | null; - listener = () => { - console.log("WebSocket successfully reconnected!"); - websocket.removeEventListener("open", listener); +/** + * Handle WebSocket reconnection with exponential backoff + * @private + */ +async function reconnect(): Promise { + if (isReconnecting) { + return; } - websocket.addEventListener("open", listener); - websocket.addEventListener("error", onError); + isReconnecting = true; + + // Clean up old connection + cleanupWebSocket(); + + const delayMs = getReconnectDelay(reconnectAttempts); + reconnectAttempts++; + + await delay(delayMs); + + try { + websocket = create(); + setupEventHandlers(); + } catch (error) { + // If creation fails, try again + isReconnecting = false; + reconnect(); + } +} + +/** + * Setup event handlers for the WebSocket connection + * @private + */ +function setupEventHandlers(): void { + // Message handler + messageHandler = (e: MessageEvent) => { + try { + const response: WebSocketMessage = JSON.parse(e.data); + + // Handle call signaling messages + if (callSignalingHandler && response.type === "call_signaling" && response.data) { + callSignalingHandler.handleWebSocketMessage(response.data); + } + + // Handle status and typing messages + if (response.type === "statusUpdate") { + onlineStatusManager.handleStatusUpdate(response as any); + } else if (response.type === "typing") { + typingManager.handleTyping(response as any); + } else if (response.type === "stopTyping") { + typingManager.handleStopTyping(response as any); + } else if (response.type === "dmTyping") { + typingManager.handleDmTyping(response as any); + } else if (response.type === "stopDmTyping") { + typingManager.handleStopDmTyping(response as any); + } else if (response.type === "suspended") { + // Handle account suspension + const { setSuspended } = useAppState.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(); + logout(); + // Close WebSocket connection + websocket.close(); + } + + // Route message to global handler if set + if (globalMessageHandler) { + globalMessageHandler(response); + } + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } + }; + websocket.addEventListener("message", messageHandler); + + // Open handler + openHandler = () => { + reconnectAttempts = 0; // Reset on successful connection + isReconnecting = false; + }; + 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) => { + clearTimeout(timeoutId); + try { + resolve(JSON.parse(e.data)); + } catch (error) { + reject(error); + } + websocket.removeEventListener("message", listener); + }; + + 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 // -------------- -websocket.addEventListener("message", (e) => { - try { - const response: WebSocketMessage = JSON.parse(e.data); - - // Handle call signaling messages - if (callSignalingHandler && response.type === "call_signaling" && response.data) { - callSignalingHandler.handleWebSocketMessage(response.data); - } - - // Handle status and typing messages - if (response.type === "statusUpdate") { - onlineStatusManager.handleStatusUpdate(response as any); - } else if (response.type === "typing") { - typingManager.handleTyping(response as any); - } else if (response.type === "stopTyping") { - typingManager.handleStopTyping(response as any); - } else if (response.type === "dmTyping") { - typingManager.handleDmTyping(response as any); - } else if (response.type === "stopDmTyping") { - typingManager.handleStopDmTyping(response as any); - } else if (response.type === "suspended") { - // Handle account suspension - const { setSuspended } = useAppState.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(); - logout(); - // Close WebSocket connection - websocket.close(); - } - - // Route message to global handler if set - if (globalMessageHandler) { - globalMessageHandler(response); - } - } catch (error) { - console.error("Error parsing WebSocket message:", error); - } -}); -websocket.addEventListener("error", onError); \ No newline at end of file +setupEventHandlers(); \ No newline at end of file diff --git a/frontend/src/css/_components.scss b/frontend/src/css/_components.scss index 454d843..0f066b9 100644 --- a/frontend/src/css/_components.scss +++ b/frontend/src/css/_components.scss @@ -53,7 +53,7 @@ button, input { } &.warning { - color: #ff9800; // Orange color for warnings + color: $color-dark-tertiary; // Purple-themed warning color } &.small mdui-icon { diff --git a/frontend/src/css/_material.scss b/frontend/src/css/_material.scss index 281337f..b9c53c2 100644 --- a/frontend/src/css/_material.scss +++ b/frontend/src/css/_material.scss @@ -54,11 +54,6 @@ $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/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index 375f340..932a64f 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -25,6 +25,7 @@ justify-content: center; align-items: center; padding: 16px; + user-select: none; .logo { $size: 35px; From a15d3a08bfc38e1cec2a7254b88cebca4cfda819 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 22:02:44 +0300 Subject: [PATCH 22/59] Refactor APIs --- .../api/{devicesApi.ts => account/devices.ts} | 9 +- .../core/api/{authApi.ts => account/index.ts} | 175 +++++++---- frontend/src/core/api/account/profile.ts | 275 ++++++++++++++++++ frontend/src/core/api/crypto.ts | 76 +++++ frontend/src/core/api/dm.ts | 178 ++++++++++++ frontend/src/core/api/dmApi.ts | 35 +-- frontend/src/core/api/files.ts | 39 +++ frontend/src/core/api/messaging.ts | 87 ++++++ frontend/src/core/api/moderation.ts | 54 ++++ frontend/src/core/api/profileApi.ts | 66 ++++- frontend/src/core/api/push.ts | 50 ++++ frontend/src/core/api/securityApi.ts | 36 --- frontend/src/core/api/users.ts | 28 ++ frontend/src/core/api/webrtc.ts | 15 + frontend/src/core/calls/encryption.ts | 2 +- frontend/src/core/calls/webrtc.ts | 21 +- frontend/src/core/components/StatusBadge.tsx | 2 +- frontend/src/core/components/VerifyButton.tsx | 2 +- .../push-notifications/push-notifications.ts | 14 +- frontend/src/pages/auth/LoginForm.tsx | 33 +-- frontend/src/pages/auth/RegisterForm.tsx | 26 +- frontend/src/pages/chat/hooks/useDM.ts | 2 +- frontend/src/pages/chat/hooks/useProfile.ts | 2 +- frontend/src/pages/chat/state.ts | 14 +- frontend/src/pages/chat/ui/ChatPage.tsx | 2 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 44 +-- .../pages/chat/ui/left/UnifiedChatsList.tsx | 19 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 2 +- .../chat/ui/left/settings/AccountPanel.tsx | 2 +- .../ui/left/settings/ChangePasswordDialog.tsx | 2 +- .../chat/ui/left/settings/DevicesPanel.tsx | 2 +- .../ui/left/settings/NotificationsPanel.tsx | 12 +- frontend/src/pages/chat/ui/right/Message.tsx | 5 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 4 +- .../chat/ui/right/panels/PublicChatPanel.ts | 53 +--- 35 files changed, 1075 insertions(+), 313 deletions(-) rename frontend/src/core/api/{devicesApi.ts => account/devices.ts} (84%) rename frontend/src/core/api/{authApi.ts => account/index.ts} (55%) create mode 100644 frontend/src/core/api/account/profile.ts create mode 100644 frontend/src/core/api/crypto.ts create mode 100644 frontend/src/core/api/dm.ts create mode 100644 frontend/src/core/api/files.ts create mode 100644 frontend/src/core/api/messaging.ts create mode 100644 frontend/src/core/api/moderation.ts create mode 100644 frontend/src/core/api/push.ts delete mode 100644 frontend/src/core/api/securityApi.ts create mode 100644 frontend/src/core/api/users.ts create mode 100644 frontend/src/core/api/webrtc.ts diff --git a/frontend/src/core/api/devicesApi.ts b/frontend/src/core/api/account/devices.ts similarity index 84% rename from frontend/src/core/api/devicesApi.ts rename to frontend/src/core/api/account/devices.ts index 12882cd..aaa139d 100644 --- a/frontend/src/core/api/devicesApi.ts +++ b/frontend/src/core/api/account/devices.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/authApi"; +import { getAuthHeaders } from "./index"; export interface DeviceInfo { session_id: string; @@ -18,20 +18,19 @@ export interface DeviceInfo { } export async function listDevices(token: string): Promise { - 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 55% rename from frontend/src/core/api/authApi.ts rename to frontend/src/core/api/account/index.ts index a95c08f..af2318b 100644 --- a/frontend/src/core/api/authApi.ts +++ b/frontend/src/core/api/account/index.ts @@ -1,12 +1,15 @@ -import type { Headers, UploadPublicKeyRequest, BackupBlob } from "@/core/types"; +import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest, RegisterRequest, LoginResponse } 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 { 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 +26,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 +42,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 +61,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 +180,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/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/dm.ts b/frontend/src/core/api/dm.ts new file mode 100644 index 0000000..b0cc194 --- /dev/null +++ b/frontend/src/core/api/dm.ts @@ -0,0 +1,178 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { randomBytes } from "@/utils/crypto/kdf"; +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..b0cc194 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,12 +1,14 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./authApi"; +import { getAuthHeaders } from "./account"; 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 { 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 +25,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 +34,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 +176,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..01fe6bd --- /dev/null +++ b/frontend/src/core/api/files.ts @@ -0,0 +1,39 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +/** + * Gets the URL for a normal (unencrypted) file + */ +export function getNormalFileUrl(filename: string): string { + return `${API_BASE_URL}/uploads/files/normal/${filename}`; +} + +/** + * Gets the URL for an encrypted file + */ +export function getEncryptedFileUrl(filename: string): string { + return `${API_BASE_URL}/uploads/files/encrypted/${filename}`; +} + +/** + * Fetches a normal file (unencrypted) + */ +export async function fetchNormalFile(filename: string, token: string): Promise { + const res = await fetch(getNormalFileUrl(filename), { + headers: getAuthHeaders(token, false) + }); + if (!res.ok) throw new Error("Failed to fetch file"); + return await res.blob(); +} + +/** + * Fetches an encrypted file + */ +export async function fetchEncryptedFile(filename: string, token: string): Promise { + const res = await fetch(getEncryptedFileUrl(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/messaging.ts b/frontend/src/core/api/messaging.ts new file mode 100644 index 0000000..6cc3261 --- /dev/null +++ b/frontend/src/core/api/messaging.ts @@ -0,0 +1,87 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; +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 { + 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) { + const error = await res.text(); + throw new Error(error || "Failed to send message with files"); + } +} + +/** + * 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) throw new Error("Failed to edit message"); +} + +/** + * 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/profileApi.ts b/frontend/src/core/api/profileApi.ts index 5a25935..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"; @@ -208,3 +208,67 @@ export async function checkUserSimilarity(userId: number, token: string): Promis 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 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..253b5cb --- /dev/null +++ b/frontend/src/core/api/push.ts @@ -0,0 +1,50 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "./account"; + +export interface PushSubscriptionRequest { + endpoint: string; + keys: { + p256dh: string; + auth: string; + }; +} + +export interface PushSubscriptionResponse { + status: string; + message: string; +} + +/** + * Subscribes the current user to push notifications + */ +export async function subscribeToPush( + 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 + */ +export async function unsubscribeFromPush(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/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..e08c213 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -2,7 +2,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/sy import { randomBytes } from "@/utils/crypto/kdf"; import { b64, ub64 } from "@/utils/utils"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { getCurrentKeys } from "@/core/api/authApi"; +import { getCurrentKeys } from "@/core/api/account"; import type { WrappedSessionKeyPayload } from "@/core/types"; export interface CallSessionKey { diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index 1680908..470e42c 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,8 +1,9 @@ -import { getAuthHeaders, getAuthToken } from "@/core/api/authApi"; -import type { CallSignalingMessage, IceServersResponse, WrappedSessionKeyPayload } from "@/core/types"; +import { getAuthToken } from "@/core/api/account"; +import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; +import { getIceServers as fetchIceServers } from "@/core/api/webrtc"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; -import { fetchUserPublicKey } from "@/core/api/dmApi"; +import { fetchUserPublicKey } from "@/core/api/dm"; import { importAesGcmKey } from "@/utils/crypto/symmetric"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; @@ -99,16 +100,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 = getAuthToken(); + if (!token) throw new Error("No auth token"); + const data = await fetchIceServers(token); + return data.iceServers || []; } catch (error) { console.warn("Failed to fetch ICE servers:", error); } diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index bce9df8..b8755d2 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { checkUserSimilarity } from "@/core/api/profileApi"; +import { checkUserSimilarity } from "@/core/api/account/profile"; import { useAppState } from "@/pages/chat/state"; import { MaterialIcon } from "@/utils/material"; diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 07ed3de..4ddfa54 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { verifyUser } from "@/core/api/profileApi"; +import { verifyUser } from "@/core/api/account/profile"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton } from "@/utils/material"; diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index a0760dd..ac94d75 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -1,4 +1,4 @@ -import { API_BASE_URL } from "@/core/config"; +import { subscribeToPush } from "@/core/api/push"; import { isElectron } from "@/core/electron/electron"; import { websocket } from "@/core/websocket"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; @@ -89,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 subscribeToPush(subscriptionData, token); + return true; } catch (error) { console.error("Failed to send subscription to server:", error); return false; diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index be6b091..4d3a930 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -2,11 +2,10 @@ 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 { ErrorResponse, LoginRequest, LoginResponse } from "@/core/types"; -import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest } from "@/core/types"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import { initialize, isSupported, startElectronReceiver, subscribe } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; @@ -86,16 +85,8 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { 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(); + try { + const data = await login(request); setUser(data.token, data.user); try { @@ -126,20 +117,16 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { } catch (e) { console.error("Notification setup failed:", e); } - } else { - const data: ErrorResponse = await response.json(); - - if (response.status === 403 && response.headers.get("suspension_reason")) { - const suspensionReason = response.headers.get("suspension_reason"); + } catch (error: any) { + if (error.message && error.message.includes("suspension")) { const setSuspended = useAppState.getState().setSuspended; - setSuspended(suspensionReason || "No reason provided"); + setSuspended(error.message || "No reason provided"); return; } - - showAlert("danger", data.message || "Неверное имя пользователя или пароль"); + showAlert("danger", error.message || "Неверное имя пользователя или пароль"); } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); } finally { setIsLoading(false); } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index b3e5c02..9ef19b5 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -2,11 +2,10 @@ 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 { ErrorResponse, RegisterRequest, LoginResponse } from "@/core/types"; -import { API_BASE_URL } from "@/core/config"; +import type { RegisterRequest } from "@/core/types"; import { useAppState } from "@/pages/chat/state"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret } from "@/core/api/authApi"; +import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import type { Alert, AlertType } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth"; @@ -115,16 +114,8 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { 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(); + try { + const data = await register(request); setUser(data.token, data.user); try { @@ -134,12 +125,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { } navigate("/chat"); - } else { - const data: ErrorResponse = await response.json(); - showAlert("danger", data.message || "Ошибка при регистрации"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка при регистрации"); } - } catch (error) { - showAlert("danger", "Ошибка соединения с сервером"); + } catch (error: any) { + showAlert("danger", error.message || "Ошибка соединения с сервером"); } finally { setIsLoading(false); } diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 28dfb5f..e652b18 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -7,7 +7,7 @@ import { sendDMViaWebSocket, fetchDMConversations, type DMConversationResponse -} from "@/core/api/dmApi"; +} from "@/core/api/dm"; import type { User, Message, DmEncryptedJSON } from "@/core/types"; import { websocket } from "@/core/websocket"; diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index 3e0eb65..aa3f57b 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,6 +1,6 @@ import { useState, useCallback, useEffect } from "react"; import { useAppState } from "@/pages/chat/state"; -import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/profileApi"; +import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts index 52679f4..7aea4c1 100644 --- a/frontend/src/pages/chat/state.ts +++ b/frontend/src/pages/chat/state.ts @@ -4,9 +4,9 @@ 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 { restoreKeys } from "@/core/api/account"; import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "@/core/api/account"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -298,12 +298,12 @@ export const useAppState = create((set, get) => ({ const token = localStorage.getItem('authToken'); if (token) { - const response = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token) + // Fetch full user profile + const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { + headers: getAuthHeaders(token, true) }); - - if (response.ok) { - const user: User = await response.json(); + if (fullResponse.ok) { + const user: User = await fullResponse.json(); restoreKeys(); // Check if user is suspended diff --git a/frontend/src/pages/chat/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 0a67f6f..33f8ade 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -5,7 +5,7 @@ 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 { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import styles from "@/pages/chat/css/layout.module.scss"; export default function ChatPage() { diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index b576348..11546bb 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -4,7 +4,7 @@ import type { ProfileDialogData } from "@/pages/chat/state"; 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 { updateProfile, uploadProfilePicture, fetchUserProfileById, suspendUser, unsuspendUser, deleteUser } from "@/core/api/account/profile"; import { RichTextArea } from "@/core/components/RichTextArea"; import { StatusBadge } from "@/core/components/StatusBadge"; import { VerifyButton } from "@/core/components/VerifyButton"; @@ -349,37 +349,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 suspendUser(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 unsuspendUser(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 +381,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 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/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index fb40dad..0994cdb 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, useMemo } from "react"; import { useAppState } from "@/pages/chat/state"; 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 { fetchMessages } from "@/core/api/messaging"; +import { fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -51,16 +50,10 @@ export function UnifiedChatsList() { 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?.length > 0) { - const lastMessage = data.messages[data.messages.length - 1]; - setLastMessages({ general: lastMessage }); - } + const messages = await 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); diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index 9ed8dae..b37d0e1 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,6 +1,6 @@ import { useState, useEffect, useRef } from "react"; import { useAppState } from "@/pages/chat/state"; -import { searchUsers, fetchUserPublicKey } from "@/core/api/dmApi"; +import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; import { onlineStatusManager } from "@/core/onlineStatusManager"; diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 16e39a9..9061e34 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 { deleteAccount } from "@/core/api/account"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index 8b6f9f1..78a9da6 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -2,7 +2,7 @@ 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 { changePassword } from "@/core/api/account"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index ac9dbc7..b6ccc4f 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -2,7 +2,7 @@ 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 { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index d5f0931..ba5c8bf 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -3,8 +3,7 @@ import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from import { useAppState } from "@/pages/chat/state"; 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 { unsubscribeFromPush } from "@/core/api/push"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { @@ -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 unsubscribeFromPush(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/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 757cdd9..137318a 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -5,12 +5,11 @@ import Quote from "@/core/components/Quote"; import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; -import { getCurrentKeys } from "@/core/api/authApi"; +import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; 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 { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 6956d1c..dc841f4 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -6,8 +6,8 @@ import { sendDmWithFiles, editDmEnvelope, deleteDmEnvelope -} from "@/core/api/dmApi"; -import { fetchUserProfileById } from "@/core/api/profileApi"; +} from "@/core/api/dm"; +import { fetchUserProfileById } from "@/core/api/account/profile"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/pages/chat/state"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index d8c0e7f..cc89906 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 { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; export class PublicChatPanel extends MessagePanel { private messagesLoaded: boolean = false; @@ -42,18 +41,12 @@ 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 messages = await fetchMessages(this.currentUser.authToken); + if (messages && messages.length > 0) { + this.clearMessages(); + messages.forEach((msg: Message) => { + this.addMessage(msg); + }); } this.messagesLoaded = true; } catch (error) { @@ -68,35 +61,9 @@ export class PublicChatPanel extends MessagePanel { 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); - } + await sendMessage(content, replyToId ?? null, this.currentUser.authToken); } 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()); - } + await sendMessageWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); } } catch (error) { console.error("Error sending message:", error); From 0725eecd41ec86e279cd4ec95a5cab3549a7d476 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 22:18:43 +0300 Subject: [PATCH 23/59] Add SVG optimization --- frontend/plugins/optimizeSvg.ts | 67 +++++++++++++++++++++++++++++++++ frontend/vite.config.ts | 2 + package.json | 1 + 3 files changed, 70 insertions(+) create mode 100644 frontend/plugins/optimizeSvg.ts 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/vite.config.ts b/frontend/vite.config.ts index ab5318a..719e614 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, diff --git a/package.json b/package.json index d59d2b4..db5d7ce 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,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", From c6c60e640446729cba13a23170f88f23d24e82ce Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 22:26:52 +0300 Subject: [PATCH 24/59] Simplify code --- frontend/src/pages/ProtectedRoute.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index dec0ff5..9f1569f 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -1,21 +1,13 @@ -import { useEffect } from "react"; +import type { ReactNode } from "react"; import { useAppState } from "./chat/state"; -import { useNavigate } from "react-router-dom"; +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(); - useEffect(() => { - if (!user.authToken) { - navigate("/login"); - return; - } - }, [user.authToken, user.currentUser, navigate]); - - return <>{children}; + return !user.authToken ? : children; } From dc4536cabc33340de3b130012f3e012bd7cce2d8 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 23:21:30 +0300 Subject: [PATCH 25/59] Refactor state --- frontend/src/App.tsx | 8 +- frontend/src/core/components/StatusBadge.tsx | 4 +- frontend/src/core/components/VerifyButton.tsx | 4 +- frontend/src/core/onlineStatusManager.ts | 4 +- frontend/src/core/typingManager.ts | 10 +- frontend/src/core/websocket.ts | 6 +- frontend/src/pages/ProtectedRoute.tsx | 4 +- frontend/src/pages/auth/LoginForm.tsx | 6 +- frontend/src/pages/auth/RegisterForm.tsx | 4 +- frontend/src/pages/chat/hooks/useCall.ts | 59 +- frontend/src/pages/chat/hooks/useDM.ts | 8 +- frontend/src/pages/chat/hooks/useProfile.ts | 4 +- frontend/src/pages/chat/state.ts | 728 ------------------ frontend/src/pages/chat/ui/ChatPage.tsx | 8 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 16 +- .../src/pages/chat/ui/left/ChatHeader.tsx | 6 +- frontend/src/pages/chat/ui/left/LeftPanel.tsx | 4 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 12 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 12 +- .../chat/ui/left/settings/AccountPanel.tsx | 4 +- .../ui/left/settings/ChangePasswordDialog.tsx | 4 +- .../chat/ui/left/settings/DevicesPanel.tsx | 4 +- .../ui/left/settings/NotificationsPanel.tsx | 4 +- .../pages/chat/ui/right/ChatMainHeader.tsx | 4 +- .../src/pages/chat/ui/right/ChatMessages.tsx | 4 +- frontend/src/pages/chat/ui/right/Message.tsx | 8 +- .../chat/ui/right/MessageContextMenu.tsx | 4 +- .../chat/ui/right/MessagePanelRenderer.tsx | 37 +- .../pages/chat/ui/right/OnlineIndicator.tsx | 6 +- .../src/pages/chat/ui/right/OnlineStatus.tsx | 8 +- .../src/pages/chat/ui/right/RightPanel.tsx | 6 +- .../pages/chat/ui/right/calls/CallWindow.tsx | 9 +- .../chat/ui/right/calls/MinimizedCallBar.tsx | 7 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 2 +- .../chat/ui/right/panels/MessagePanel.ts | 2 +- .../chat/ui/right/panels/PublicChatPanel.ts | 2 +- frontend/src/pages/home/HomePage.tsx | 4 +- frontend/src/state/call.ts | 123 +++ frontend/src/state/chat.ts | 150 ++++ frontend/src/state/presence.ts | 41 + frontend/src/state/profile.ts | 14 + frontend/src/state/types.ts | 73 ++ frontend/src/state/user.ts | 159 ++++ 43 files changed, 720 insertions(+), 866 deletions(-) delete mode 100644 frontend/src/pages/chat/state.ts create mode 100644 frontend/src/state/call.ts create mode 100644 frontend/src/state/chat.ts create mode 100644 frontend/src/state/presence.ts create mode 100644 frontend/src/state/profile.ts create mode 100644 frontend/src/state/types.ts create mode 100644 frontend/src/state/user.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 8cb4602..73e1724 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,7 +1,7 @@ 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 { useUserStore } from "./state/user"; import { lazy, useEffect, useRef, useState } from "react"; import { parseProfileLink } from "./core/profileLinks"; import NotFoundPage from "./pages/not-found/NotFoundPage"; @@ -117,14 +117,14 @@ function AnimatedRoutes() { } export default function App() { - const { restoreUserFromStorage, user } = useAppState(); + const { restoreFromStorage, user } = useUserStore(); const [authReady, setAuthReady] = useState(false); useEffect(() => { - restoreUserFromStorage().finally(() => { + restoreFromStorage().finally(() => { setAuthReady(true); }); - }, [restoreUserFromStorage]); + }, [restoreFromStorage]); return authReady && ( diff --git a/frontend/src/core/components/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index b8755d2..6268a45 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/account/profile"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialIcon } from "@/utils/material"; interface StatusBadgeProps { @@ -11,7 +11,7 @@ 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}`; diff --git a/frontend/src/core/components/VerifyButton.tsx b/frontend/src/core/components/VerifyButton.tsx index 4ddfa54..111a6ac 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/account/profile"; -import { useAppState } from "@/pages/chat/state"; +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) { 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/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/websocket.ts b/frontend/src/core/websocket.ts index d4400c1..6eea282 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -11,7 +11,7 @@ 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"; /** * Creates a new WebSocket connection to the chat server @@ -170,14 +170,14 @@ function setupEventHandlers(): void { 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(); diff --git a/frontend/src/pages/ProtectedRoute.tsx b/frontend/src/pages/ProtectedRoute.tsx index 9f1569f..c4c51cc 100644 --- a/frontend/src/pages/ProtectedRoute.tsx +++ b/frontend/src/pages/ProtectedRoute.tsx @@ -1,5 +1,5 @@ import type { ReactNode } from "react"; -import { useAppState } from "./chat/state"; +import { useUserStore } from "@/state/user"; import { Navigate } from "react-router-dom"; interface ProtectedRouteProps { @@ -7,7 +7,7 @@ interface ProtectedRouteProps { } export default function ProtectedRoute({ children }: ProtectedRouteProps) { - const { user } = useAppState(); + const { user } = useUserStore(); return !user.authToken ? : children; } diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 4d3a930..2f74010 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -3,7 +3,7 @@ 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 { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; @@ -53,7 +53,7 @@ interface LoginFormProps { export function LoginForm({ onSwitchMode }: LoginFormProps) { const [isLoading, setIsLoading] = useState(false); const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); + const setUser = useUserStore(state => state.setUser); const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { @@ -119,7 +119,7 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { } } catch (error: any) { if (error.message && error.message.includes("suspension")) { - const setSuspended = useAppState.getState().setSuspended; + const setSuspended = useUserStore.getState().setSuspended; setSuspended(error.message || "No reason provided"); return; } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index 9ef19b5..29961ed 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -3,7 +3,7 @@ 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 { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; @@ -51,7 +51,7 @@ interface RegisterFormProps { export function RegisterForm({ onSwitchMode }: RegisterFormProps) { const [isLoading, setIsLoading] = useState(false); const [alerts, updateAlerts] = useImmer([]); - const setUser = useAppState(state => state.setUser); + const setUser = useUserStore(state => state.setUser); const navigate = useNavigate(); function showAlert(type: AlertType, message: string) { 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 e652b18..92fb749 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { fetchUserPublicKey, fetchDMHistory, @@ -44,7 +45,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); @@ -295,7 +297,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) { diff --git a/frontend/src/pages/chat/hooks/useProfile.ts b/frontend/src/pages/chat/hooks/useProfile.ts index aa3f57b..9a8abff 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,10 +1,10 @@ import { useState, useCallback, useEffect } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/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); diff --git a/frontend/src/pages/chat/state.ts b/frontend/src/pages/chat/state.ts deleted file mode 100644 index 7aea4c1..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 { restoreKeys } from "@/core/api/account"; -import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/account"; -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) { - // Fetch full user profile - const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token, true) - }); - if (fullResponse.ok) { - const user: User = await fullResponse.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 33f8ade..3c489a7 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -4,7 +4,8 @@ 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 { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import styles from "@/pages/chat/css/layout.module.scss"; @@ -12,7 +13,8 @@ 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) @@ -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 11546bb..34234d0 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -1,6 +1,7 @@ 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"; @@ -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; diff --git a/frontend/src/pages/chat/ui/left/ChatHeader.tsx b/frontend/src/pages/chat/ui/left/ChatHeader.tsx index 67c476d..d975b0b 100644 --- a/frontend/src/pages/chat/ui/left/ChatHeader.tsx +++ b/frontend/src/pages/chat/ui/left/ChatHeader.tsx @@ -2,14 +2,16 @@ 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({ 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); function handleProfileClick() { diff --git a/frontend/src/pages/chat/ui/left/LeftPanel.tsx b/frontend/src/pages/chat/ui/left/LeftPanel.tsx index 3287784..1f0fb9d 100644 --- a/frontend/src/pages/chat/ui/left/LeftPanel.tsx +++ b/frontend/src/pages/chat/ui/left/LeftPanel.tsx @@ -1,4 +1,4 @@ -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { useRef, useState } from "react"; import { SettingsDialog } from "./settings/SettingsDialog"; import { UsernameSearch } from "./UsernameSearch"; @@ -9,7 +9,7 @@ import styles from "@/pages/chat/css/left-panel.module.scss"; function BottomAppBar({ bottomAppBarRef }: { bottomAppBarRef?: React.RefObject }) { const [settingsOpen, onSettingsOpenChange] = useState(false); - const { logout } = useAppState(); + const { logout } = useUserStore(); return ( <> diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index 0994cdb..a58c17e 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback, useMemo } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { useDM, type DMUser } from "@/pages/chat/hooks/useDM"; import { fetchMessages } from "@/core/api/messaging"; import { fetchUserPublicKey } from "@/core/api/dm"; @@ -42,7 +43,8 @@ const PUBLIC_CHAT: PublicChat = { }; export function UnifiedChatsList() { - const { user, switchToPublicChat, switchToDM, chat } = useAppState(); + const { user } = useUserStore(); + const { switchToPublicChat, switchToDM, activeTab } = useChatStore(); const { dmUsers, isLoadingUsers, loadUsers } = useDM(); const [lastMessages, setLastMessages] = useState>({}); @@ -61,11 +63,11 @@ export function UnifiedChatsList() { }, [user.authToken]); useEffect(() => { - if (chat.activeTab === "chats") { + if (activeTab === "chats") { loadUsers(); loadLastMessages(); } - }, [chat.activeTab, loadUsers, loadLastMessages]); + }, [activeTab, loadUsers, loadLastMessages]); const allChats = useMemo(() => { return [ @@ -155,7 +157,7 @@ export function UnifiedChatsList() { 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); diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index b37d0e1..fc7d60b 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useRef } from "react"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useChatStore } from "@/state/chat"; import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; @@ -23,7 +24,8 @@ export interface UsernameSearchProps { } export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: UsernameSearchProps) { - const { user, switchToDM, chat } = useAppState(); + const { user } = useUserStore(); + const { switchToDM, activeDm } = useChatStore(); const [searchQuery, setSearchQuery] = useState(""); const [searchResults, setSearchResults] = useState([]); const [isSearching, setIsSearching] = useState(false); @@ -68,7 +70,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use // 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); @@ -101,12 +103,12 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use // 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) { diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index 9061e34..db428f3 100644 --- a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx @@ -1,5 +1,5 @@ import { MaterialList, MaterialListItem } from "@/utils/material"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; import { deleteAccount } from "@/core/api/account"; 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() { diff --git a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx index 78a9da6..cc3c52c 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 { useUserStore } from "@/state/user"; import { changePassword } from "@/core/api/account"; 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(""); diff --git a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx index b6ccc4f..3a5e252 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -1,13 +1,13 @@ 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 { useUserStore } from "@/state/user"; import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/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); diff --git a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx index ba5c8bf..3b60c2d 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -1,13 +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 { unsubscribeFromPush } from "@/core/api/push"; 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); 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 137318a..a87837e 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -8,7 +8,8 @@ import { useEffect, useState, useRef, useMemo } from "react"; import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { useAppState } from "@/pages/chat/state"; +import { useUserStore } from "@/state/user"; +import { useProfileStore } from "@/state/profile"; import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; @@ -25,7 +26,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); @@ -162,7 +163,8 @@ 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; 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..aeb2abc 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,7 +52,8 @@ 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); @@ -121,30 +126,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 +173,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 +200,7 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { } } - const panelKey = chat.activePanel?.getState().title || "empty"; + const panelKey = activePanel?.getState().title || "empty"; return (
diff --git a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx index 49d05f8..cc5da49 100644 --- a/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx +++ b/frontend/src/pages/chat/ui/right/OnlineIndicator.tsx @@ -5,7 +5,7 @@ * @version 1.0.0 */ -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; import styles from "@/pages/chat/css/TypingIndicators.module.scss"; interface OnlineIndicatorProps { @@ -14,8 +14,8 @@ interface OnlineIndicatorProps { } export function OnlineIndicator({ userId, className = "" }: OnlineIndicatorProps) { - const { chat } = useAppState(); - const status = chat.onlineStatuses.get(userId); + const { onlineStatuses } = usePresenceStore(); + const status = onlineStatuses.get(userId); // Only show indicator when user is online if (!status || !status.online) { diff --git a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx index 4b8c028..6aa6615 100644 --- a/frontend/src/pages/chat/ui/right/OnlineStatus.tsx +++ b/frontend/src/pages/chat/ui/right/OnlineStatus.tsx @@ -5,7 +5,8 @@ * @version 1.0.0 */ -import { useAppState } from "@/pages/chat/state"; +import { usePresenceStore } from "@/state/presence"; +import { useUserStore } from "@/state/user"; import styles from "@/pages/chat/css/TypingIndicators.module.scss"; interface OnlineStatusProps { @@ -14,8 +15,9 @@ interface OnlineStatusProps { } export function OnlineStatus({ userId, showLastSeen = false }: OnlineStatusProps) { - const { chat, user } = useAppState(); - const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : chat.onlineStatuses.get(userId); + const { onlineStatuses } = usePresenceStore(); + const { user } = useUserStore(); + const status = userId === user.currentUser?.id ? { online: true, lastSeen: new Date().toISOString() } : onlineStatuses.get(userId); function formatLastSeen(lastSeen: string): string { const date = new Date(lastSeen); diff --git a/frontend/src/pages/chat/ui/right/RightPanel.tsx b/frontend/src/pages/chat/ui/right/RightPanel.tsx index caf5f2b..aeaf173 100644 --- a/frontend/src/pages/chat/ui/right/RightPanel.tsx +++ b/frontend/src/pages/chat/ui/right/RightPanel.tsx @@ -1,8 +1,8 @@ -import { useAppState } from "@/pages/chat/state"; +import { useChatStore } from "@/state/chat"; import { MessagePanelRenderer } from "./MessagePanelRenderer"; export function RightPanel() { - const { chat } = useAppState(); + const { activePanel } = useChatStore(); - return + 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 dc841f4..1591e5b 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -9,7 +9,7 @@ import { } from "@/core/api/dm"; import { fetchUserProfileById } from "@/core/api/account/profile"; 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"; diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index 805ed8b..4b38f0d 100644 --- a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts @@ -1,5 +1,5 @@ import type { Message, WebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; export interface MessagePanelState { id: string; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index cc89906..3e9e216 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -1,7 +1,7 @@ import { MessagePanel } from "./MessagePanel"; import { request } from "@/core/websocket"; import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; -import type { UserState, ProfileDialogData } from "@/pages/chat/state"; +import type { UserState, ProfileDialogData } from "@/state/types"; import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; export class PublicChatPanel extends MessagePanel { 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/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..bffd7ca --- /dev/null +++ b/frontend/src/state/user.ts @@ -0,0 +1,159 @@ +import { create } from "zustand"; +import type { User } from "@/core/types"; +import { request } from "@/core/websocket"; +import { restoreKeys } from "@/core/api/account"; +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "@/core/api/account"; +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); + } + + try { + request({ + type: "ping", + credentials: { + scheme: "Bearer", + credentials: token + }, + data: {} + }) + } catch {} + }, + 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: getAuthHeaders(token, true) + }); + if (fullResponse.ok) { + const user: User = await fullResponse.json(); + 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); + + try { + request({ + type: "ping", + credentials: { + scheme: "Bearer", + credentials: token + }, + data: {} + }) + } catch {} + + 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 + } + })) +})); From 718b6ee27a458235c57f8dacb5b7c23ad7b89a2d Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 19 Nov 2025 16:05:57 +0300 Subject: [PATCH 26/59] Remove file --- SECURITY_FOLLOWUPS.md | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 SECURITY_FOLLOWUPS.md diff --git a/SECURITY_FOLLOWUPS.md b/SECURITY_FOLLOWUPS.md deleted file mode 100644 index 659f5d1..0000000 --- a/SECURITY_FOLLOWUPS.md +++ /dev/null @@ -1,9 +0,0 @@ -# Security Follow-ups - -- Integrate log shipping/alerting (e.g. Loki or ELK) so events from `backend/logs/*.log` raise actionable notifications instead of remaining on disk. -- Add automated review of `security.log` for repeated `auth_bruteforce_detected` and burst messaging entries; trigger temporary IP bans or captcha challenges when thresholds are exceeded. -- Extend profanity filtering tests to cover dynamic blocklist updates and multi-language phrases; add regression suite to ensure adult-content words remain blocked. -- Implement DM spam heuristics similar to public chat (rate limiting, reaction abuse detection) and log attempts that target users who blocked the sender. -- Harden WebSocket session handling by recycling DB sessions per request or adopting async session factories to keep long-lived connections from retaining database handles indefinitely. -- Wire the new moderator blocklist endpoints into an authenticated UI workflow so operators can manage entries without shell access, and audit every change with responsible operator metadata. - From 0f4256edad9b7659d17791d6c05426a883ec2390 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 19 Nov 2025 16:07:43 +0300 Subject: [PATCH 27/59] Hide dependencies folders --- .vscode/settings.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index 3c122bb..06d3519 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -2,6 +2,9 @@ "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 } } \ No newline at end of file From 7d254b565840b638f949e90676dd3e90bca91143 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 23 Nov 2025 21:33:09 +0300 Subject: [PATCH 28/59] Change the structure --- frontend/src/core/api/calls.ts | 16 ++ frontend/src/core/api/chats/dm.ts | 194 +++++++++++++++ frontend/src/core/api/chats/general.ts | 99 ++++++++ frontend/src/core/api/crypto/backup.ts | 37 +++ frontend/src/core/api/crypto/identity.ts | 45 ++++ frontend/src/core/api/crypto/prekeys.ts | 14 ++ frontend/src/core/api/files.ts | 70 +++--- frontend/src/core/api/index.ts | 50 ++++ frontend/src/core/api/moderation/blocklist.ts | 55 +++++ frontend/src/core/api/moderation/users.ts | 89 +++++++ frontend/src/core/api/push.ts | 66 +++--- frontend/src/core/api/user/auth.ts | 221 ++++++++++++++++++ frontend/src/core/api/user/devices.ts | 37 +++ frontend/src/core/api/user/profile.ts | 191 +++++++++++++++ frontend/src/core/api/user/search.ts | 40 ++++ frontend/src/core/calls/encryption.ts | 8 +- frontend/src/core/calls/webrtc.ts | 14 +- frontend/src/core/components/StatusBadge.tsx | 4 +- frontend/src/core/components/VerifyButton.tsx | 4 +- .../push-notifications/push-notifications.ts | 4 +- frontend/src/pages/auth/LoginForm.tsx | 8 +- frontend/src/pages/auth/RegisterForm.tsx | 8 +- frontend/src/pages/chat/hooks/useDM.ts | 48 ++-- frontend/src/pages/chat/hooks/useProfile.ts | 9 +- frontend/src/pages/chat/ui/ChatPage.tsx | 6 +- frontend/src/pages/chat/ui/ProfileDialog.tsx | 14 +- .../pages/chat/ui/left/UnifiedChatsList.tsx | 7 +- .../src/pages/chat/ui/left/UsernameSearch.tsx | 6 +- .../chat/ui/left/settings/AccountPanel.tsx | 4 +- .../ui/left/settings/ChangePasswordDialog.tsx | 4 +- .../chat/ui/left/settings/DevicesPanel.tsx | 9 +- .../ui/left/settings/NotificationsPanel.tsx | 4 +- frontend/src/pages/chat/ui/right/Message.tsx | 19 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 26 +-- .../chat/ui/right/panels/PublicChatPanel.ts | 8 +- frontend/src/state/user.ts | 7 +- 36 files changed, 1261 insertions(+), 184 deletions(-) create mode 100644 frontend/src/core/api/calls.ts create mode 100644 frontend/src/core/api/chats/dm.ts create mode 100644 frontend/src/core/api/chats/general.ts create mode 100644 frontend/src/core/api/crypto/backup.ts create mode 100644 frontend/src/core/api/crypto/identity.ts create mode 100644 frontend/src/core/api/crypto/prekeys.ts create mode 100644 frontend/src/core/api/index.ts create mode 100644 frontend/src/core/api/moderation/blocklist.ts create mode 100644 frontend/src/core/api/moderation/users.ts create mode 100644 frontend/src/core/api/user/auth.ts create mode 100644 frontend/src/core/api/user/devices.ts create mode 100644 frontend/src/core/api/user/profile.ts create mode 100644 frontend/src/core/api/user/search.ts 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..98dc1c5 --- /dev/null +++ b/frontend/src/core/api/chats/dm.ts @@ -0,0 +1,194 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; +import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { randomBytes } from "@/utils/crypto/kdf"; +import { getCurrentKeys } from "../user/auth"; +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/identity"; +import { fetchUsers, searchUsers } from "../user/search"; + +export async function decrypt(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 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 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 sendWithFiles(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 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 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 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 }; + + 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/backup.ts b/frontend/src/core/api/crypto/backup.ts new file mode 100644 index 0000000..3354c5c --- /dev/null +++ b/frontend/src/core/api/crypto/backup.ts @@ -0,0 +1,37 @@ +import { API_BASE_URL } from "@/core/config"; +import { getAuthHeaders } from "../user/auth"; +import type { BackupBlob } from "@/core/types"; + +/** + * 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/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/files.ts b/frontend/src/core/api/files.ts index 01fe6bd..c9e8186 100644 --- a/frontend/src/core/api/files.ts +++ b/frontend/src/core/api/files.ts @@ -1,39 +1,43 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./account"; +import { getAuthHeaders } from "./user/auth"; -/** - * Gets the URL for a normal (unencrypted) file - */ -export function getNormalFileUrl(filename: string): string { - return `${API_BASE_URL}/uploads/files/normal/${filename}`; -} +export const normal = { + /** + * Gets the URL for a normal (unencrypted) file + */ + url(filename: string): string { + return `${API_BASE_URL}/uploads/files/normal/${filename}`; + }, -/** - * Gets the URL for an encrypted file - */ -export function getEncryptedFileUrl(filename: string): string { - return `${API_BASE_URL}/uploads/files/encrypted/${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(); + } +}; -/** - * Fetches a normal file (unencrypted) - */ -export async function fetchNormalFile(filename: string, token: string): Promise { - const res = await fetch(getNormalFileUrl(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 - */ -export async function fetchEncryptedFile(filename: string, token: string): Promise { - const res = await fetch(getEncryptedFileUrl(filename), { - headers: getAuthHeaders(token, false) - }); - if (!res.ok) throw new Error("Failed to fetch encrypted file"); - return await res.blob(); -} + /** + * 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/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/push.ts b/frontend/src/core/api/push.ts index 253b5cb..31ae7bb 100644 --- a/frontend/src/core/api/push.ts +++ b/frontend/src/core/api/push.ts @@ -1,5 +1,5 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "./account"; +import { getAuthHeaders } from "./user/auth"; export interface PushSubscriptionRequest { endpoint: string; @@ -14,37 +14,39 @@ export interface PushSubscriptionResponse { message: string; } -/** - * Subscribes the current user to push notifications - */ -export async function subscribeToPush( - 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(); -} +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 - */ -export async function unsubscribeFromPush(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"); + /** + * 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(); } - return await res.json(); -} +}; diff --git a/frontend/src/core/api/user/auth.ts b/frontend/src/core/api/user/auth.ts new file mode 100644 index 0000000..80b131a --- /dev/null +++ b/frontend/src/core/api/user/auth.ts @@ -0,0 +1,221 @@ +import { API_BASE_URL } from "@/core/config"; +import type { LoginRequest, RegisterRequest, LoginResponse, Headers } 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 { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; +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/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index e08c213..9ebc700 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -2,7 +2,7 @@ import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/sy import { randomBytes } from "@/utils/crypto/kdf"; import { b64, ub64 } from "@/utils/utils"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { getCurrentKeys } from "@/core/api/account"; +import api from "@/core/api"; import type { WrappedSessionKeyPayload } from "@/core/types"; export interface CallSessionKey { @@ -186,7 +186,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 +209,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 +226,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 470e42c..dbacf14 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -1,9 +1,7 @@ -import { getAuthToken } from "@/core/api/account"; +import api from "@/core/api"; import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; -import { getIceServers as fetchIceServers } from "@/core/api/webrtc"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; -import { fetchUserPublicKey } from "@/core/api/dm"; import { importAesGcmKey } from "@/utils/crypto/symmetric"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; @@ -100,9 +98,9 @@ export class WebRTCCall { */ private async getIceServers(): Promise { try { - const token = getAuthToken(); + const token = api.user.auth.getAuthToken(); if (!token) throw new Error("No auth token"); - const data = await fetchIceServers(token); + const data = await api.calls.iceServers(token); return data.iceServers || []; } catch (error) { console.warn("Failed to fetch ICE servers:", error); @@ -774,7 +772,7 @@ async function sendSignalingMessage(message: CallSignalingMessage) { type: "call_signaling", credentials: { scheme: "Bearer", - credentials: getAuthToken()! + credentials: api.user.auth.getAuthToken()! }, data: message }); @@ -857,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; @@ -892,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/StatusBadge.tsx b/frontend/src/core/components/StatusBadge.tsx index 6268a45..9ec282d 100644 --- a/frontend/src/core/components/StatusBadge.tsx +++ b/frontend/src/core/components/StatusBadge.tsx @@ -1,5 +1,5 @@ import { useState, useEffect } from "react"; -import { checkUserSimilarity } from "@/core/api/account/profile"; +import api from "@/core/api"; import { useUserStore } from "@/state/user"; import { MaterialIcon } from "@/utils/material"; @@ -18,7 +18,7 @@ export function StatusBadge({ verified, userId, size = "small" }: StatusBadgePro // 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 111a6ac..391b8e1 100644 --- a/frontend/src/core/components/VerifyButton.tsx +++ b/frontend/src/core/components/VerifyButton.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { verifyUser } from "@/core/api/account/profile"; +import api from "@/core/api"; import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; @@ -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/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index ac94d75..7ca24cb 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -1,4 +1,4 @@ -import { subscribeToPush } from "@/core/api/push"; +import api from "@/core/api"; import { isElectron } from "@/core/electron/electron"; import { websocket } from "@/core/websocket"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; @@ -89,7 +89,7 @@ async function sendSubscriptionToServer(token: string): Promise { }; try { - await subscribeToPush(subscriptionData, token); + await api.push.subscription.subscribe(subscriptionData, token); return true; } catch (error) { console.error("Failed to send subscription to server:", error); diff --git a/frontend/src/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 2f74010..8e1410d 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -5,7 +5,7 @@ import { useImmer } from "use-immer"; import type { LoginRequest } from "@/core/types"; import { useUserStore } from "@/state/user"; import { MaterialButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret, login } from "@/core/api/account"; +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"; @@ -79,18 +79,18 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { setIsLoading(true); try { - const derived = await deriveAuthSecret(username, password); + const derived = await api.user.auth.deriveAuthSecret(username, password); const request: LoginRequest = { username: username, password: derived } try { - const data = await login(request); + const data = await api.user.auth.login(request); setUser(data.token, data.user); try { - await ensureKeysOnLogin(password, data.token); + await api.user.auth.ensureKeysOnLogin(password, data.token); } catch (e) { console.error("Key setup failed:", e); } diff --git a/frontend/src/pages/auth/RegisterForm.tsx b/frontend/src/pages/auth/RegisterForm.tsx index 29961ed..81fdfa4 100644 --- a/frontend/src/pages/auth/RegisterForm.tsx +++ b/frontend/src/pages/auth/RegisterForm.tsx @@ -5,7 +5,7 @@ import { useImmer } from "use-immer"; import type { RegisterRequest } from "@/core/types"; import { useUserStore } from "@/state/user"; import { MaterialButton, MaterialIconButton } from "@/utils/material"; -import { ensureKeysOnLogin, deriveAuthSecret, register } from "@/core/api/account"; +import api from "@/core/api"; import { AuthTextField, type AuthTextFieldHandle } from "./AuthTextField"; import type { Alert, AlertType } from "./Auth"; import { AuthHeader, AlertsContainer } from "./Auth"; @@ -106,7 +106,7 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { setIsLoading(true); try { - const derived = await deriveAuthSecret(username, password); + const derived = await api.user.auth.deriveAuthSecret(username, password); const request: RegisterRequest = { display_name: displayName, username: username, @@ -115,11 +115,11 @@ export function RegisterForm({ onSwitchMode }: RegisterFormProps) { } try { - const data = await register(request); + const data = await api.user.auth.register(request); setUser(data.token, data.user); try { - await ensureKeysOnLogin(password, data.token); + await api.user.auth.ensureKeysOnLogin(password, data.token); } catch (e) { console.error("Key setup failed:", e); } diff --git a/frontend/src/pages/chat/hooks/useDM.ts b/frontend/src/pages/chat/hooks/useDM.ts index 92fb749..579760c 100644 --- a/frontend/src/pages/chat/hooks/useDM.ts +++ b/frontend/src/pages/chat/hooks/useDM.ts @@ -1,14 +1,8 @@ import { useState, useEffect, useCallback, useRef } from "react"; import { useUserStore } from "@/state/user"; import { useChatStore } from "@/state/chat"; -import { - fetchUserPublicKey, - fetchDMHistory, - decryptDm, - sendDMViaWebSocket, - fetchDMConversations, - type DMConversationResponse -} from "@/core/api/dm"; +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"; @@ -58,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 @@ -70,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); } @@ -107,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) { @@ -121,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!); } @@ -143,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); @@ -163,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"; @@ -214,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); } @@ -228,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; } @@ -257,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) { @@ -270,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!); } @@ -322,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); @@ -352,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 9a8abff..9c7d1af 100644 --- a/frontend/src/pages/chat/hooks/useProfile.ts +++ b/frontend/src/pages/chat/hooks/useProfile.ts @@ -1,6 +1,7 @@ import { useState, useCallback, useEffect } from "react"; import { useUserStore } from "@/state/user"; -import { loadProfile, updateProfile, uploadProfilePicture, type ProfileData } from "@/core/api/account/profile"; +import api from "@/core/api"; +import type { ProfileData } from "@/core/api/user/profile"; import { showSuccess, showError } from "@/utils/notification"; export default function useProfile() { @@ -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/ui/ChatPage.tsx b/frontend/src/pages/chat/ui/ChatPage.tsx index 3c489a7..89a9795 100644 --- a/frontend/src/pages/chat/ui/ChatPage.tsx +++ b/frontend/src/pages/chat/ui/ChatPage.tsx @@ -6,7 +6,7 @@ import { useEffect, useRef } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; +import api from "@/core/api"; import styles from "@/pages/chat/css/layout.module.scss"; export default function ChatPage() { @@ -43,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) { diff --git a/frontend/src/pages/chat/ui/ProfileDialog.tsx b/frontend/src/pages/chat/ui/ProfileDialog.tsx index 34234d0..0a52dab 100644 --- a/frontend/src/pages/chat/ui/ProfileDialog.tsx +++ b/frontend/src/pages/chat/ui/ProfileDialog.tsx @@ -5,7 +5,7 @@ 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, suspendUser, unsuspendUser, deleteUser } from "@/core/api/account/profile"; +import api from "@/core/api"; import { RichTextArea } from "@/core/components/RichTextArea"; import { StatusBadge } from "@/core/components/StatusBadge"; import { VerifyButton } from "@/core/components/VerifyButton"; @@ -98,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, @@ -285,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 @@ -294,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); } } @@ -351,7 +351,7 @@ export function ProfileDialog() { }); if (reason) { - const result = await suspendUser(currentData.userId, reason, user.authToken!); + const result = await api.moderation.users.suspend(currentData.userId, reason, user.authToken!); if (result) { closeProfileDialog(); } else { @@ -360,7 +360,7 @@ export function ProfileDialog() { } } else { // Unsuspend user - const result = await unsuspendUser(currentData.userId, user.authToken!); + const result = await api.moderation.users.unsuspend(currentData.userId, user.authToken!); if (result) { closeProfileDialog(); } else { @@ -383,7 +383,7 @@ export function ProfileDialog() { cancelText: "Cancel" }); - const result = await deleteUser(currentData.userId, user.authToken!); + const result = await api.moderation.users.deleteUser(currentData.userId, user.authToken!); if (result) { closeProfileDialog(); diff --git a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx index a58c17e..485db7a 100644 --- a/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx +++ b/frontend/src/pages/chat/ui/left/UnifiedChatsList.tsx @@ -2,8 +2,7 @@ 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 { fetchMessages } from "@/core/api/messaging"; -import { fetchUserPublicKey } from "@/core/api/dm"; +import api from "@/core/api"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { Message } from "@/core/types"; import { websocket } from "@/core/websocket"; @@ -52,7 +51,7 @@ export function UnifiedChatsList() { if (!user.authToken) return; try { - const messages = await fetchMessages(user.authToken, 1); + const { messages } = await api.chats.general.fetchMessages(user.authToken, 1); if (messages?.length > 0) { const lastMessage = messages[messages.length - 1]; setLastMessages({ general: lastMessage }); @@ -160,7 +159,7 @@ export function UnifiedChatsList() { const authToken = useUserStore.getState().user.authToken; if (!authToken) return; - const publicKey = await fetchUserPublicKey(dmConversation.id, authToken); + const publicKey = await api.chats.dm.fetchUserPublicKey(dmConversation.id, authToken); if (!publicKey) { console.error("Failed to get public key for user:", dmConversation.id); return; diff --git a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx index fc7d60b..7184e46 100644 --- a/frontend/src/pages/chat/ui/left/UsernameSearch.tsx +++ b/frontend/src/pages/chat/ui/left/UsernameSearch.tsx @@ -1,7 +1,7 @@ import { useState, useEffect, useRef } from "react"; import { useUserStore } from "@/state/user"; import { useChatStore } from "@/state/chat"; -import { searchUsers, fetchUserPublicKey } from "@/core/api/dm"; +import api from "@/core/api"; import { StatusBadge } from "@/core/components/StatusBadge"; import type { User } from "@/core/types"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -45,7 +45,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use 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); @@ -118,7 +118,7 @@ export function UsernameSearch({ containerRef, headerRef, bottomAppBarRef }: Use 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; } diff --git a/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx b/frontend/src/pages/chat/ui/left/settings/AccountPanel.tsx index db428f3..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 { useUserStore } from "@/state/user"; -import { deleteAccount } from "@/core/api/account"; +import api from "@/core/api"; import { confirm } from "mdui/functions/confirm"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; @@ -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 cc3c52c..c56058f 100644 --- a/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx +++ b/frontend/src/pages/chat/ui/left/settings/ChangePasswordDialog.tsx @@ -2,7 +2,7 @@ import { useState } from "react"; import { StyledDialog } from "@/core/components/StyledDialog"; import type { DialogProps } from "@/core/types"; import { useUserStore } from "@/state/user"; -import { changePassword } from "@/core/api/account"; +import api from "@/core/api"; import { MaterialButton, MaterialIconButton, MaterialSwitch, MaterialTextField } from "@/utils/material"; import styles from "@/pages/chat/css/changePasswordDialog.module.scss"; @@ -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 3a5e252..810611c 100644 --- a/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/DevicesPanel.tsx @@ -2,7 +2,8 @@ import { useState, useEffect } from "react"; import { useImmer } from "use-immer"; import { MaterialList, MaterialListItem, MaterialButton, MaterialCircularProgress } from "@/utils/material"; import { useUserStore } from "@/state/user"; -import { listDevices, revokeDevice, logoutAllOtherDevices, type DeviceInfo } from "@/core/api/account/devices"; +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"; @@ -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 3b60c2d..ea84bf7 100644 --- a/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx +++ b/frontend/src/pages/chat/ui/left/settings/NotificationsPanel.tsx @@ -3,7 +3,7 @@ import { MaterialList, MaterialListItem, MaterialSwitch, type MDUISwitch } from import { useUserStore } from "@/state/user"; import { initialize, subscribe, unsubscribe, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; -import { unsubscribeFromPush } from "@/core/api/push"; +import api from "@/core/api"; import styles from "@/pages/chat/css/settings-dialog.module.scss"; export function NotificationsPanel() { @@ -73,7 +73,7 @@ export function NotificationsPanel() { } // Then unsubscribe from server - await unsubscribeFromPush(authToken); + 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/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index a87837e..79fdd3d 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -5,12 +5,11 @@ import Quote from "@/core/components/Quote"; import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; -import { getCurrentKeys, getAuthHeaders } from "@/core/api/account"; +import api from "@/core/api"; import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; -import { fetchUserProfileById, fetchUserProfile } from "@/core/api/account/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; import { ub64 } from "@/utils/utils"; import { useImmer } from "use-immer"; @@ -223,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 @@ -343,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"); @@ -381,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"); @@ -405,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, @@ -434,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) { diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 1591e5b..b300709 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -1,13 +1,5 @@ import { MessagePanel } from "./MessagePanel"; -import { - fetchDMHistory, - decryptDm, - sendDMViaWebSocket, - sendDmWithFiles, - editDmEnvelope, - deleteDmEnvelope -} from "@/core/api/dm"; -import { fetchUserProfileById } from "@/core/api/account/profile"; +import api from "@/core/api"; import type { DmEncryptedJSON, DmEnvelope, DMWebSocketMessage, EncryptedMessageJson, Message } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/state/types"; import { formatDMUsername } from "@/pages/chat/hooks/useDM"; @@ -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,7 @@ export class DMPanel extends MessagePanel { this.setLoading(true); try { - const messages = await fetchDMHistory(this.dmData.userId, this.currentUser.authToken, 50); + const { messages } = await api.chats.dm.fetchMessages(this.dmData.userId, this.currentUser.authToken, 50); const decryptedMessages: Message[] = []; let maxIncomingId = 0; @@ -157,14 +149,14 @@ export class DMPanel extends MessagePanel { const json = JSON.stringify(payload); if (files.length === 0) { - await sendDMViaWebSocket( + await api.chats.dm.send( this.dmData.userId, this.dmData.publicKey, json, this.currentUser.authToken ); } else { - await sendDmWithFiles( + await api.chats.dm.sendWithFiles( this.dmData.userId, this.dmData.publicKey, json, @@ -228,7 +220,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 +322,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 +337,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 +346,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/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index 3e9e216..3457758 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -2,7 +2,7 @@ import { MessagePanel } from "./MessagePanel"; import { request } from "@/core/websocket"; import type { ChatWebSocketMessage, Message, ReactionUpdateWebSocketMessage } from "@/core/types"; import type { UserState, ProfileDialogData } from "@/state/types"; -import { fetchMessages, sendMessage, sendMessageWithFiles } from "@/core/api/messaging"; +import api from "@/core/api"; export class PublicChatPanel extends MessagePanel { private messagesLoaded: boolean = false; @@ -41,7 +41,7 @@ export class PublicChatPanel extends MessagePanel { this.setLoading(true); try { - const messages = await fetchMessages(this.currentUser.authToken); + const { messages } = await api.chats.general.fetchMessages(this.currentUser.authToken); if (messages && messages.length > 0) { this.clearMessages(); messages.forEach((msg: Message) => { @@ -61,9 +61,9 @@ export class PublicChatPanel extends MessagePanel { try { if (files.length === 0) { - await sendMessage(content, replyToId ?? null, this.currentUser.authToken); + await api.chats.general.send(content, replyToId ?? null, this.currentUser.authToken); } else { - await sendMessageWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); + await api.chats.general.sendWithFiles(content, replyToId ?? null, files, this.currentUser.authToken); } } catch (error) { console.error("Error sending message:", error); diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index bffd7ca..3d2675d 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -1,9 +1,8 @@ import { create } from "zustand"; import type { User } from "@/core/types"; import { request } from "@/core/websocket"; -import { restoreKeys } from "@/core/api/account"; +import api from "@/core/api"; import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "@/core/api/account"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; import { isElectron } from "@/core/electron/electron"; import { onlineStatusManager } from "@/core/onlineStatusManager"; @@ -84,11 +83,11 @@ export const useUserStore = create((set) => ({ if (token) { const fullResponse = await fetch(`${API_BASE_URL}/user/profile`, { - headers: getAuthHeaders(token, true) + headers: api.user.auth.getAuthHeaders(token, true) }); if (fullResponse.ok) { const user: User = await fullResponse.json(); - restoreKeys(); + api.user.auth.restoreKeys(); if (user.suspended) { set({ From 857365361d5400f838ee17141190714db6feeb54 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 25 Nov 2025 16:29:37 +0300 Subject: [PATCH 29/59] Implement robust reconnection system, updates, optimize typing --- backend/models.py | 15 + backend/routes/messaging.py | 583 ++++++++++++++---- frontend/src/core/updateManager.ts | 126 ++++ frontend/src/core/websocket.ts | 130 +++- .../chat/ui/right/MessagePanelRenderer.tsx | 108 +++- .../src/pages/chat/ui/right/panels/DMPanel.ts | 48 +- .../chat/ui/right/panels/MessagePanel.ts | 27 +- .../chat/ui/right/panels/PublicChatPanel.ts | 33 +- frontend/src/state/user.ts | 25 +- package.json | 1 + 10 files changed, 892 insertions(+), 204 deletions(-) create mode 100644 frontend/src/core/updateManager.ts diff --git a/backend/models.py b/backend/models.py index 8d581ee..5f4c71e 100644 --- a/backend/models.py +++ b/backend/models.py @@ -283,5 +283,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/routes/messaging.py b/backend/routes/messaging.py index bd75081..b9ff6f8 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -19,7 +19,7 @@ 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 @@ -360,7 +360,7 @@ async def _send_message_internal( await messagingManager.broadcast({ "type": "newMessage", "data": convert_message(new_message) - }) + }, db) except Exception: pass @@ -798,7 +798,7 @@ async def add_reaction( "username": current_user.username, "reactions": message_data["reactions"] } - }) + }, db) except Exception: pass @@ -871,7 +871,7 @@ async def add_dm_reaction( "username": current_user.username, "reactions": envelope_data["reactions"] } - }) + }, db) except Exception: pass @@ -894,12 +894,193 @@ 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]: + 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]) > 100: + # Remove oldest entries (simple FIFO by converting to list and keeping last 100) + # Actually, we'll just clear and rebuild on next flush - simpler approach + pass + + 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() @@ -926,11 +1107,43 @@ class MessaggingSocketManager: ) while True: - data = await websocket.receive_json() + try: + data = await websocket.receive_json() + except Exception as e: + logger.error(f"Error receiving WebSocket message: {e}") + break + type = data["type"] def get_current_user_inner() -> User | None: - if data["credentials"]: + try: + # Ensure session is in a usable state before querying + try: + db.rollback() + except Exception: + pass + + if data.get("credentials"): + dummy_request = SimpleNamespace() + dummy_request.state = SimpleNamespace() + return get_current_user( + dummy_request, + HTTPAuthorizationCredentials( + scheme=data["credentials"]["scheme"], + credentials=data["credentials"]["credentials"] + ), + db + ) + else: + return None + except Exception as e: + logger.error(f"Error getting current user: {e}") + try: + db.rollback() + except Exception: + pass + return None + if data.get("credentials"): dummy_request = SimpleNamespace() dummy_request.state = SimpleNamespace() return get_current_user( @@ -944,7 +1157,63 @@ class MessaggingSocketManager: else: return None - if type == "ping": + if type == "getUpdates": + # Handle gap detection - client requests updates from a specific sequence number + current_user: User | None = None + try: + current_user = get_current_user_inner() + if not current_user: + raise HTTPException(401) + + last_seq = data.get("data", {}).get("lastSeq", 0) + self.last_seq_by_ws[websocket] = last_seq + current_seq = self.sequence_numbers.get(current_user.id, 0) + + # Query database for missed updates + missed_updates = [] + if last_seq > 0 and last_seq < current_seq: + try: + import json + # Get all updates between last_seq and current_seq + update_logs = db.query(UpdateLog).filter( + UpdateLog.user_id == current_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 in update_logs: + updates = json.loads(log.updates) + missed_updates.append({ + "seq": log.sequence, + "updates": updates + }) + except Exception as e: + logger.error(f"Failed to retrieve missed updates: {e}") + + # Send missed updates + for batch in missed_updates: + await websocket.send_json({ + "type": "updates", + "seq": batch["seq"], + "updates": batch["updates"] + }) + + await websocket.send_json({ + "type": "getUpdates", + "data": { + "status": "ok", + "lastSeq": current_seq, + "missedCount": len(missed_updates) + } + }) + # Update the websocket's last sequence tracking + self.last_seq_by_ws[websocket] = current_seq + _log_ws("getUpdates", current_user, last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates)) + except HTTPException as e: + _log_ws("getUpdates_error", current_user, detail=str(getattr(e, "detail", e))) + await self.send_error(websocket, type, e) + elif type == "ping": current_user: User | None = None try: current_user = get_current_user_inner() @@ -957,7 +1226,7 @@ class MessaggingSocketManager: # 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()) + await self.broadcast_status_change(current_user.id, True, current_user.last_seen.isoformat(), db) else: await websocket.send_json({ "type": "ping", @@ -1012,7 +1281,7 @@ class MessaggingSocketManager: await self.broadcast({ "type": "newMessage", "data": response["message"] - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("sendMessage", current_user, message_id=response["message"]["id"]) @@ -1067,9 +1336,9 @@ class MessaggingSocketManager: 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 self.send_update_to_user(env.recipient_id, "dmNew", payload["data"], db); await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); - await self.send_to_user(env.sender_id, payload); + await self.send_update_to_user(env.sender_id, "dmNew", payload["data"], db); _log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id) log_dm( @@ -1097,7 +1366,7 @@ class MessaggingSocketManager: await self.broadcast({ "type": "messageEdited", "data": response["message"] - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("editMessage", current_user, message_id=message_id) @@ -1142,8 +1411,8 @@ class MessaggingSocketManager: "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 self.send_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db) + await self.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db) await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) _log_ws("dmEdit", current_user, dm_envelope_id=env.id) @@ -1182,9 +1451,9 @@ class MessaggingSocketManager: "recipientId": payload.get("recipientId") } } - await self.send_to_user(env.recipient_id, payload_ws) + await self.send_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db) await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) - await self.send_to_user(env.sender_id, payload_ws) + await self.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db) _log_ws("dmDelete", current_user, dm_envelope_id=env_id) log_dm( @@ -1209,7 +1478,7 @@ class MessaggingSocketManager: await self.broadcast({ "type": "messageDeleted", "data": {"message_id": message_id} - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("deleteMessage", current_user, message_id=message_id) @@ -1242,7 +1511,7 @@ class MessaggingSocketManager: "username": current_user.username, "reactions": response["reactions"] } - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"]) @@ -1275,7 +1544,7 @@ class MessaggingSocketManager: "username": current_user.username, "reactions": response["reactions"] } - }) + }, db) await websocket.send_json({"type": type, "data": response}) _log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"]) @@ -1328,15 +1597,12 @@ class MessaggingSocketManager: # 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 self.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_video_toggle", + "fromUserId": current_user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) except HTTPException as e: @@ -1361,15 +1627,12 @@ class MessaggingSocketManager: # 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 self.send_update_to_user(to_user_id, "call_signaling", { + "type": "call_screen_share_toggle", + "fromUserId": current_user.id, + "toUserId": to_user_id, + "data": {"enabled": payload.get("enabled", False)} + }, db) await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) except HTTPException as e: @@ -1431,18 +1694,23 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) + was_typing = self.typing_state.get(current_user.id, False) self.typing_users[current_user.id] = time.time() + is_now_typing = True - # Broadcast to all connected users - await self.broadcast({ - "type": "typing", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }) + # Only send update if state changed (started typing) + if not was_typing: + self.typing_state[current_user.id] = True + # Broadcast to all connected users + await self.broadcast({ + "type": "typing", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }, db) - await websocket.send_json({"type": "typing", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: _log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) @@ -1455,19 +1723,23 @@ class MessaggingSocketManager: if not current_user: raise HTTPException(401) + was_typing = self.typing_state.get(current_user.id, False) 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 - } - }) + # Only send update if state changed (stopped typing) + if was_typing: + self.typing_state[current_user.id] = False + # Broadcast to all connected users + await self.broadcast({ + "type": "stopTyping", + "data": { + "userId": current_user.id, + "username": current_user.username + } + }, db) - await websocket.send_json({"type": "stopTyping", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: _log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e))) await self.send_error(websocket, type, e) @@ -1483,18 +1755,22 @@ class MessaggingSocketManager: if current_user.id not in self.dm_typing_users: self.dm_typing_users[current_user.id] = {} + if current_user.id not in self.dm_typing_state: + self.dm_typing_state[current_user.id] = {} + + was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) 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": { + # Only send update if state changed (started typing) + if not was_typing: + self.dm_typing_state[current_user.id][recipient_id] = True + # Send only to recipient + await self.send_update_to_user(recipient_id, "dmTyping", { "userId": current_user.id, "username": current_user.username - } - }) + }, db) - await websocket.send_json({"type": "dmTyping", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: await self.send_error(websocket, type, e) elif type == "stopDmTyping": @@ -1505,21 +1781,26 @@ class MessaggingSocketManager: recipient_id = int(data["data"]["recipientId"]) + was_typing = False + if current_user.id in self.dm_typing_state: + was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) + 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": { + # Only send update if state changed (stopped typing) + if was_typing: + if current_user.id in self.dm_typing_state: + self.dm_typing_state[current_user.id][recipient_id] = False + # Send only to recipient + await self.send_update_to_user(recipient_id, "stopDmTyping", { "userId": current_user.id, "username": current_user.username - } - }) + }, db) - await websocket.send_json({"type": "stopDmTyping", "data": {"status": "ok"}}) + # No confirmation response - privacy protection except HTTPException as e: await self.send_error(websocket, type, e) else: @@ -1540,6 +1821,9 @@ class MessaggingSocketManager: 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: @@ -1553,69 +1837,96 @@ class MessaggingSocketManager: 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: @@ -1629,15 +1940,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 = [] @@ -1647,18 +1966,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) @@ -1669,7 +1997,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/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 6eea282..0915d19 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -12,6 +12,8 @@ import { CallSignalingHandler } from "./calls/signaling"; import { onlineStatusManager } from "./onlineStatusManager"; import { typingManager } from "./typingManager"; import { useUserStore } from "@/state/user"; +import { getLastSequence, processBatchedUpdates, requestMissedUpdates } from "./updateManager"; +import { getAuthToken } from "@/core/api/user/auth"; /** * Creates a new WebSocket connection to the chat server @@ -148,55 +150,119 @@ async function reconnect(): Promise { */ function setupEventHandlers(): void { // Message handler - messageHandler = (e: MessageEvent) => { + 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 - if (response.type === "statusUpdate") { - onlineStatusManager.handleStatusUpdate(response as any); - } else if (response.type === "typing") { - typingManager.handleTyping(response as any); - } else if (response.type === "stopTyping") { - typingManager.handleStopTyping(response as any); - } else if (response.type === "dmTyping") { - typingManager.handleDmTyping(response as any); - } else if (response.type === "stopDmTyping") { - typingManager.handleStopDmTyping(response as any); - } else if (response.type === "suspended") { - // Handle account suspension - 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 } = useUserStore.getState(); - logout(); - // Close WebSocket connection - websocket.close(); - } - - // Route message to global handler if set - if (globalMessageHandler) { - globalMessageHandler(response); - } + // 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); } }; + + // 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") { + typingManager.handleTyping(response as any); + } else if (response.type === "stopTyping") { + typingManager.handleStopTyping(response as any); + } else if (response.type === "dmTyping") { + typingManager.handleDmTyping(response as any); + } else if (response.type === "stopDmTyping") { + typingManager.handleStopDmTyping(response as any); + } else if (response.type === "suspended") { + // Handle account suspension + 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 } = useUserStore.getState(); + logout(); + // Close WebSocket connection + websocket.close(); + } + + // Route message to global handler if set + if (globalMessageHandler) { + globalMessageHandler(response); + } + } websocket.addEventListener("message", messageHandler); // Open handler - openHandler = () => { + 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); diff --git a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx index aeb2abc..edb356b 100644 --- a/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx +++ b/frontend/src/pages/chat/ui/right/MessagePanelRenderer.tsx @@ -58,6 +58,8 @@ export function MessagePanelRenderer({ panel }: MessagePanelRendererProps) { 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); @@ -92,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) { @@ -280,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)} + > +
+ + ) : (
this.addMessage(msg)); + this.setHasMoreMessages(has_more); // Update last read ID if (maxIncomingId > 0) { @@ -135,6 +137,50 @@ 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; diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index 4b38f0d..322a469 100644 --- a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts @@ -9,6 +9,8 @@ export interface MessagePanelState { messages: Message[]; isLoading: boolean; isTyping: boolean; + hasMoreMessages: boolean; + isLoadingMore: boolean; } export interface MessagePanelCallbacks { @@ -35,7 +37,9 @@ export abstract class MessagePanel { online: false, messages: [], isLoading: false, - isTyping: false + isTyping: false, + hasMoreMessages: false, + isLoadingMore: false }; this.currentUser = currentUser; } @@ -107,6 +111,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 }; diff --git a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts index 3457758..72c2e4e 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -41,13 +41,15 @@ export class PublicChatPanel extends MessagePanel { this.setLoading(true); try { - const { messages } = await api.chats.general.fetchMessages(this.currentUser.authToken); + 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); @@ -56,6 +58,35 @@ 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; diff --git a/frontend/src/state/user.ts b/frontend/src/state/user.ts index 3d2675d..8c8e036 100644 --- a/frontend/src/state/user.ts +++ b/frontend/src/state/user.ts @@ -1,6 +1,5 @@ import { create } from "zustand"; import type { User } from "@/core/types"; -import { request } from "@/core/websocket"; import api from "@/core/api"; import { API_BASE_URL } from "@/core/config"; import { initialize, subscribe, startElectronReceiver, isSupported } from "@/core/push-notifications/push-notifications"; @@ -44,16 +43,8 @@ export const useUserStore = create((set) => ({ console.error('Failed to store credentials in localStorage:', error); } - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} + // Ping will be sent automatically on WebSocket reconnect + // No need to send here to avoid duplicate pings }, logout: () => { try { @@ -113,16 +104,8 @@ export const useUserStore = create((set) => ({ onlineStatusManager.setAuthToken(token); typingManager.setAuthToken(token); - try { - request({ - type: "ping", - credentials: { - scheme: "Bearer", - credentials: token - }, - data: {} - }) - } catch {} + // Ping will be sent automatically on WebSocket reconnect + // No need to send here to avoid duplicate pings try { if (isSupported()) { diff --git a/package.json b/package.json index db5d7ce..5a46993 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", "he": "^1.2.0", + "idb": "^8.0.3", "marked": "^16.3.0", "mdui": "^2.1.4", "motion": "^12.23.24", From c68cb2818c20bb9d3e333ccddda9fc25b1bd53b0 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 25 Nov 2025 19:43:42 +0300 Subject: [PATCH 30/59] Refactor the websocket message handler --- backend/routes/messaging.py | 736 ++-------------------------------- backend/websocket/__init__.py | 7 + backend/websocket/handlers.py | 576 ++++++++++++++++++++++++++ backend/websocket/registry.py | 33 ++ backend/websocket/utils.py | 92 +++++ 5 files changed, 735 insertions(+), 709 deletions(-) create mode 100644 backend/websocket/__init__.py create mode 100644 backend/websocket/handlers.py create mode 100644 backend/websocket/registry.py create mode 100644 backend/websocket/utils.py diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index b9ff6f8..7f2a2cd 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -28,6 +28,7 @@ from better_profanity import profanity as _bp from security.audit import log_access, log_dm, log_public_chat, log_security from security.profanity import censor_text from security.rate_limit import rate_limit_per_ip +from websocket.utils import authenticate_user router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -1084,27 +1085,9 @@ class MessaggingSocketManager: async def handle_connection(self, websocket: WebSocket, db: Session): # Initialize subscriptions for this connection self.ws_subscriptions[websocket] = set() - - 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) - - def _log_ws(event: str, user: User | None, **extra: Any) -> 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, - ) + + # Import here to avoid circular import + from websocket.handlers import handler_registry while True: try: @@ -1113,698 +1096,33 @@ class MessaggingSocketManager: logger.error(f"Error receiving WebSocket message: {e}") break - type = data["type"] - - def get_current_user_inner() -> User | None: + message_type = data["type"] + handler_info = handler_registry.get_handler(message_type) + + if handler_info: + handler, authRequired = handler_info try: - # Ensure session is in a usable state before querying - try: - db.rollback() - except Exception: - pass + # 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 - if data.get("credentials"): - dummy_request = SimpleNamespace() - dummy_request.state = SimpleNamespace() - return get_current_user( - dummy_request, - HTTPAuthorizationCredentials( - scheme=data["credentials"]["scheme"], - credentials=data["credentials"]["credentials"] - ), - db - ) - else: - return None + # 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, message_type, e) + except WebSocketDisconnect: + raise # Re-raise to close connection except Exception as e: - logger.error(f"Error getting current user: {e}") - try: - db.rollback() - except Exception: - pass - return None - if data.get("credentials"): - dummy_request = SimpleNamespace() - dummy_request.state = SimpleNamespace() - return get_current_user( - dummy_request, - HTTPAuthorizationCredentials( - scheme=data["credentials"]["scheme"], - credentials=data["credentials"]["credentials"] - ), - db - ) - else: - return None - - if type == "getUpdates": - # Handle gap detection - client requests updates from a specific sequence number - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - last_seq = data.get("data", {}).get("lastSeq", 0) - self.last_seq_by_ws[websocket] = last_seq - current_seq = self.sequence_numbers.get(current_user.id, 0) - - # Query database for missed updates - missed_updates = [] - if last_seq > 0 and last_seq < current_seq: - try: - import json - # Get all updates between last_seq and current_seq - update_logs = db.query(UpdateLog).filter( - UpdateLog.user_id == current_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 in update_logs: - updates = json.loads(log.updates) - missed_updates.append({ - "seq": log.sequence, - "updates": updates - }) - except Exception as e: - logger.error(f"Failed to retrieve missed updates: {e}") - - # Send missed updates - for batch in missed_updates: - await websocket.send_json({ - "type": "updates", - "seq": batch["seq"], - "updates": batch["updates"] - }) - - await websocket.send_json({ - "type": "getUpdates", - "data": { - "status": "ok", - "lastSeq": current_seq, - "missedCount": len(missed_updates) - } - }) - # Update the websocket's last sequence tracking - self.last_seq_by_ws[websocket] = current_seq - _log_ws("getUpdates", current_user, last_seq=last_seq, current_seq=current_seq, missed_count=len(missed_updates)) - except HTTPException as e: - _log_ws("getUpdates_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "ping": - current_user: User | None = None - 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(), db) - else: - await websocket.send_json({ - "type": "ping", - "data": { - "status": "error", - "error": { - "detail": "Failed to authorize", - "code": 401 - } - } - }) - _log_ws("ping_error", current_user) - except HTTPException: - await websocket.send_json({ - "type": "ping", - "data": { - "status": "error", - "error": { - "detail": "Failed to authorize", - "code": 401 - } - } - }) - _log_ws("ping_error", current_user) - await websocket.send_json({"type": "ping", "data": {"status": "success"}}) - _log_ws("ping", current_user) - elif type == "getMessages": - current_user: User | None = None - 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)}) - _log_ws("getMessages", current_user) - except HTTPException as e: - _log_ws("getMessages_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "sendMessage": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - self.user_by_ws[websocket] = current_user.id - - message_request: SendMessageRequest = SendMessageRequest.model_validate(data["data"]) - - # Call internal function directly (rate limiting is handled at infrastructure level via Caddy) - response = await _send_message_internal(message_request, current_user, db, []) - await self.broadcast({ - "type": "newMessage", - "data": response["message"] - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("sendMessage", current_user, message_id=response["message"]["id"]) - except HTTPException as e: - _log_ws("sendMessage_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "dmSend": - current_user: User | None = None - 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_update_to_user(env.recipient_id, "dmNew", payload["data"], db); - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); - await self.send_update_to_user(env.sender_id, "dmNew", payload["data"], db); - - _log_ws("dmSend", current_user, dm_envelope_id=env.id, recipient_id=env.recipient_id) - log_dm( - "message_sent_ws", - dm_envelope_id=env.id, - sender_id=current_user.id, - sender_username=current_user.username, - recipient_id=env.recipient_id, - reply_to=env.reply_to_id, - ) - except HTTPException as e: - _log_ws("dmSend_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "editMessage": - current_user: User | None = None - 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"] - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("editMessage", current_user, message_id=message_id) - except HTTPException as e: - _log_ws("editMessage_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "dmEdit": - current_user: User | None = None - 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_update_to_user(env.recipient_id, "dmEdited", payload_ws["data"], db) - await self.send_update_to_user(env.sender_id, "dmEdited", payload_ws["data"], db) - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}) - - _log_ws("dmEdit", current_user, dm_envelope_id=env.id) - log_dm( - "message_edited", - dm_envelope_id=env.id, - user_id=current_user.id, - username=current_user.username, - ) - except HTTPException as e: - _log_ws("dmEdit_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "dmDelete": - current_user: User | None = None - 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_update_to_user(env.recipient_id, "dmDeleted", payload_ws["data"], db) - await websocket.send_json({"type": type, "data": {"status": "ok", "id": env_id}}) - await self.send_update_to_user(env.sender_id, "dmDeleted", payload_ws["data"], db) - - _log_ws("dmDelete", current_user, dm_envelope_id=env_id) - log_dm( - "message_deleted", - dm_envelope_id=env_id, - user_id=current_user.id, - username=current_user.username, - recipient_id=env.recipient_id, - ) - except HTTPException as e: - _log_ws("dmDelete_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "deleteMessage": - current_user: User | None = None - 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} - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("deleteMessage", current_user, message_id=message_id) - except HTTPException as e: - _log_ws("deleteMessage_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "addReaction": - current_user: User | None = None - 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"] - } - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("addReaction", current_user, message_id=request_data["message_id"], emoji=request_data["emoji"], action=response["action"]) - except HTTPException as e: - _log_ws("addReaction_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "addDmReaction": - current_user: User | None = None - 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"] - } - }, db) - - await websocket.send_json({"type": type, "data": response}) - _log_ws("addDmReaction", current_user, dm_envelope_id=request_data["dm_envelope_id"], emoji=request_data["emoji"], action=response["action"]) - except HTTPException as e: - _log_ws("addDmReaction_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "call_signaling": - # Forward WebRTC signaling between peers - current_user: User | None = None - 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"}}) - _log_ws("call_signaling", current_user, to_user_id=to_user_id) - except HTTPException as e: - _log_ws("call_signaling_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - elif type == "call_video_toggle": - # Forward video toggle state between peers - current_user: User | None = None - 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_update_to_user(to_user_id, "call_signaling", { - "type": "call_video_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - }, db) - - await websocket.send_json({"type": "call_video_toggle", "data": {"status": "ok"}}) - except HTTPException as e: - _log_ws("call_video_toggle_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("call_video_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) - elif type == "call_screen_share_toggle": - # Forward screen share toggle state between peers - current_user: User | None = None - 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_update_to_user(to_user_id, "call_signaling", { - "type": "call_screen_share_toggle", - "fromUserId": current_user.id, - "toUserId": to_user_id, - "data": {"enabled": payload.get("enabled", False)} - }, db) - - await websocket.send_json({"type": "call_screen_share_toggle", "data": {"status": "ok"}}) - except HTTPException as e: - _log_ws("call_screen_share_toggle_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("call_screen_share_toggle", current_user, to_user_id=to_user_id, enabled=payload.get("enabled", False)) - elif type == "subscribeStatus": - current_user: User | None = None - 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() if target_user.last_seen else None - } - }) - else: - await websocket.send_json({ - "type": "subscribeStatus", - "data": {"status": "error", "error": "User not found"} - }) - except HTTPException as e: - _log_ws("subscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("subscribeStatus", current_user, target_user_id=user_id_to_subscribe) - elif type == "unsubscribeStatus": - current_user: User | None = None - 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: - _log_ws("unsubscribeStatus_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("unsubscribeStatus", current_user, target_user_id=user_id_to_unsubscribe) - elif type == "typing": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - was_typing = self.typing_state.get(current_user.id, False) - self.typing_users[current_user.id] = time.time() - is_now_typing = True - - # Only send update if state changed (started typing) - if not was_typing: - self.typing_state[current_user.id] = True - # Broadcast to all connected users - await self.broadcast({ - "type": "typing", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }, db) - - # No confirmation response - privacy protection - except HTTPException as e: - _log_ws("typing_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("typing", current_user) - elif type == "stopTyping": - current_user: User | None = None - try: - current_user = get_current_user_inner() - if not current_user: - raise HTTPException(401) - - was_typing = self.typing_state.get(current_user.id, False) - if current_user.id in self.typing_users: - del self.typing_users[current_user.id] - - # Only send update if state changed (stopped typing) - if was_typing: - self.typing_state[current_user.id] = False - # Broadcast to all connected users - await self.broadcast({ - "type": "stopTyping", - "data": { - "userId": current_user.id, - "username": current_user.username - } - }, db) - - # No confirmation response - privacy protection - except HTTPException as e: - _log_ws("stopTyping_error", current_user, detail=str(getattr(e, "detail", e))) - await self.send_error(websocket, type, e) - else: - _log_ws("stopTyping", current_user) - 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] = {} - if current_user.id not in self.dm_typing_state: - self.dm_typing_state[current_user.id] = {} - - was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) - self.dm_typing_users[current_user.id][recipient_id] = time.time() - - # Only send update if state changed (started typing) - if not was_typing: - self.dm_typing_state[current_user.id][recipient_id] = True - # Send only to recipient - await self.send_update_to_user(recipient_id, "dmTyping", { - "userId": current_user.id, - "username": current_user.username - }, db) - - # No confirmation response - privacy protection - 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"]) - - was_typing = False - if current_user.id in self.dm_typing_state: - was_typing = self.dm_typing_state[current_user.id].get(recipient_id, False) - - 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] - - # Only send update if state changed (stopped typing) - if was_typing: - if current_user.id in self.dm_typing_state: - self.dm_typing_state[current_user.id][recipient_id] = False - # Send only to recipient - await self.send_update_to_user(recipient_id, "stopDmTyping", { - "userId": current_user.id, - "username": current_user.username - }, db) - - # No confirmation response - privacy protection - except HTTPException as e: - await self.send_error(websocket, type, 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: 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..6b949e6 --- /dev/null +++ b/backend/websocket/handlers.py @@ -0,0 +1,576 @@ +from datetime import datetime +import json +import logging +import time +from typing import Any +from fastapi import HTTPException, WebSocket +from sqlalchemy.orm import Session + +from websocket.registry import WebSocketHandlerRegistry +from websocket.utils import authenticate_user +from routes.messaging import ( + MessaggingSocketManager, + convert_message, + convert_dm_envelope, + _send_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, log_public_chat +from routes.account import convert_user + +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.""" + from types import SimpleNamespace + + message_id = data["message_id"] + request: EditMessageRequest = EditMessageRequest.model_validate(data) + + # Create a dummy request object for the HTTP endpoint function + dummy_request = SimpleNamespace() + response = await edit_message(dummy_request, message_id, 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 + From 32832f81e6e8d735af2c1f47d25eb70282d62066 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 25 Nov 2025 22:42:19 +0300 Subject: [PATCH 31/59] Clean up --- backend/websocket/handlers.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/backend/websocket/handlers.py b/backend/websocket/handlers.py index 6b949e6..beee083 100644 --- a/backend/websocket/handlers.py +++ b/backend/websocket/handlers.py @@ -7,11 +7,8 @@ from fastapi import HTTPException, WebSocket from sqlalchemy.orm import Session from websocket.registry import WebSocketHandlerRegistry -from websocket.utils import authenticate_user from routes.messaging import ( MessaggingSocketManager, - convert_message, - convert_dm_envelope, _send_message_internal, get_messages, edit_message, @@ -28,8 +25,7 @@ from models import ( DMReactionRequest, UpdateLog, ) -from security.audit import log_access, log_dm, log_public_chat -from routes.account import convert_user +from security.audit import log_access, log_dm logger = logging.getLogger("uvicorn.error") From a7f88e0d2bf78fb0cf7019e61d3e4cc8ea948318 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 15:33:33 +0300 Subject: [PATCH 32/59] Improve and strenghten the profanity filter --- backend/security/profanity.py | 576 +++++++++++++++++++++++++++++++--- 1 file changed, 526 insertions(+), 50 deletions(-) diff --git a/backend/security/profanity.py b/backend/security/profanity.py index 8c49d30..d7adffb 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -2,6 +2,7 @@ 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 @@ -13,8 +14,8 @@ BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) _CUSTOM_RU_TERMS: Set[str] = { "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", - "ебать", "ебёт", "ебет", "уёбок", "уебок", "уебище", "пизда", - "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хер", "гондон", + "ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда", + "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", "пидоры", "пидорас", "пидорасы", "пидорасов", @@ -28,6 +29,12 @@ _ADULT_TERMS: Set[str] = { _STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS)) +# Words that should never be censored (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), @@ -39,42 +46,142 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( re.compile(r"\bсамсунг\s+г[ао]вно\b", 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": "о", - "o": "о", - "о": "о", - "a": "а", - "@": "а", - "4": "а", - "а": "а", - "e": "е", - "ё": "е", - "3": "е", - "c": "с", - "s": "с", - "с": "с", - "x": "х", - "х": "х", - "t": "т", - "т": "т", - "p": "п", - "п": "п", - "n": "н", - "н": "н", - "m": "м", - "м": "м", - "y": "у", - "u": "у", - "у": "у", - "g": "г", - "г": "г", - "v": "в", - "в": "в", - "f": "ф", - "ф": "ф", - "i": "и", "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 + "@": "а", } _RAW_PHRASE_GROUPS: Tuple[Tuple[str, Tuple[str, ...]], ...] = ( @@ -87,14 +194,240 @@ _PHRASE_CACHE: dict[str, Tuple[Tuple[str, ...], ...]] = {} 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() - return _LEET_MAP.get(lower, 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 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 "хU★уй" -> "хууй" + word_chars = list(word_lower) + text_chars = list(normalized_lower) + + # 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 + # 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 + seq_start = None + j = 0 + # Continue from after the start position + i = seq_start + 1 if seq_start is not None else i + 1 + continue + i += 1 + + return spans + + +def _find_profanity_spans_in_original( + normalized_text: str, + position_map: list[int], + original_length: int, + original_text: str +) -> list[tuple[int, int]]: + """ + Find profanity in normalized text and map the spans back to original text positions. + Uses both better_profanity library and substring matching for better detection. + + Returns list of (start, end) tuples in original text coordinates. + """ + spans = [] + + if not normalized_text or not position_map: + return spans + + # Check normalized text for profanity using better_profanity + censored = _profanity.censor(normalized_text, censor_char="\\*") + + # 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) + + # Combine spans from both methods + all_spans = set() + + # From better_profanity censoring + i = 0 + while i < len(censored): + if censored[i] == "*": + span_start = i + while i < len(censored) and censored[i] == "*": + i += 1 + span_end = i + all_spans.add((span_start, span_end)) + else: + i += 1 + + # From substring matching + for start, end in substring_spans: + all_spans.add((start, end)) + + # Map all spans to original positions + for span_start, span_end in all_spans: + if span_start < len(position_map): + orig_start = position_map[span_start] + # Find the end position - use the last mapped position in the span + if span_end > 0 and span_end <= len(position_map): + orig_end = position_map[span_end - 1] + 1 + elif span_end > len(position_map): + orig_end = original_length + else: + orig_end = orig_start + 1 + + # Extend span to include any non-alphanumeric characters between + # the mapped positions in the original text + # Limit extension to prevent over-censoring (max 50 chars each direction) + max_extension = 50 + extension_count = 0 + + # Extend backwards to include any preceding non-alphanumeric + while (orig_start > 0 and + not original_text[orig_start - 1].isalnum() and + extension_count < max_extension): + orig_start -= 1 + extension_count += 1 + + extension_count = 0 + # Extend forwards to include any following non-alphanumeric + while (orig_end < original_length and + not original_text[orig_end].isalnum() and + extension_count < max_extension): + orig_end += 1 + extension_count += 1 + + spans.append((orig_start, min(orig_end, original_length))) + + return spans + + def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]: tokens: List[Tuple[int, int, str]] = [] start: int | None = None @@ -241,8 +574,17 @@ def _rebuild_dictionary(force: bool = False) -> None: 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)) @@ -251,18 +593,59 @@ def _rebuild_dictionary(force: bool = False) -> None: def _apply_phrase_filters(text: str) -> str: - result = text + """ + Apply phrase patterns to text. Patterns are applied to normalized text + (without special characters) and then mapped back to original positions. + """ + # Normalize text for phrase matching (remove special chars but preserve spaces) + normalized_text, position_map = _extract_alphanumeric_with_mapping(text, preserve_spaces=True) + normalized_lower = normalized_text.lower() + + result = list(text) + censored_positions = set() + + # Apply phrase patterns to normalized text for pattern in _PHRASE_PATTERNS: - while True: - match = pattern.search(result) - if not match: - break - result = result[:match.start()] + ("*" * (match.end() - match.start())) + result[match.end():] - - for start, end in sorted(_find_fuzzy_phrase_spans(text, "generic"), reverse=True): - result = result[:start] + ("*" * (end - start)) + result[end:] - - return result + for match in pattern.finditer(normalized_lower): + # Map back to original positions + norm_start = match.start() + norm_end = match.end() + + if norm_start < len(position_map) and norm_end <= len(position_map): + orig_start = position_map[norm_start] + orig_end = position_map[norm_end - 1] + 1 if norm_end > 0 else orig_start + 1 + + # Extend to include special characters + while orig_start > 0 and not text[orig_start - 1].isalnum(): + orig_start -= 1 + while orig_end < len(text) and not text[orig_end].isalnum(): + orig_end += 1 + + # Mark positions for censoring + for pos in range(orig_start, min(orig_end, len(result))): + censored_positions.add(pos) + + # Apply fuzzy phrase spans + for start, end in sorted(_find_fuzzy_phrase_spans(normalized_lower, "generic"), reverse=True): + if start < len(position_map) and end <= len(position_map): + orig_start = position_map[start] + orig_end = position_map[end - 1] + 1 if end > 0 else orig_start + 1 + + # Extend to include special characters + while orig_start > 0 and not text[orig_start - 1].isalnum(): + orig_start -= 1 + while orig_end < len(text) and not text[orig_end].isalnum(): + orig_end += 1 + + for pos in range(orig_start, min(orig_end, len(result))): + censored_positions.add(pos) + + # Apply censoring + for pos in censored_positions: + if pos < len(result): + result[pos] = "*" + + return "".join(result) def censor_text(text: str) -> str: @@ -271,7 +654,62 @@ def censor_text(text: str) -> str: _rebuild_dictionary() preprocessed = _apply_phrase_filters(text) - return _profanity.censor(preprocessed, censor_char="\\*") + + # Normalize text for whitelist matching (to handle special characters) + normalized_for_whitelist, whitelist_position_map = _extract_alphanumeric_with_mapping(preprocessed) + normalized_for_whitelist_lower = normalized_for_whitelist.lower() + + # Identify and protect whitelisted words (using normalized text) + whitelist_spans = [] + for whitelist_word in _WHITELIST: + # Normalize whitelist word too + normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) + normalized_whitelist_lower = normalized_whitelist.lower() + + # Find in normalized text + pattern = re.compile(re.escape(normalized_whitelist_lower), re.IGNORECASE) + for match in pattern.finditer(normalized_for_whitelist_lower): + # Map back to original positions + if match.start() < len(whitelist_position_map) and match.end() <= len(whitelist_position_map): + orig_start = whitelist_position_map[match.start()] + orig_end = whitelist_position_map[match.end() - 1] + 1 if match.end() > 0 else orig_start + 1 + # Extend to include any special characters + while orig_start > 0 and not preprocessed[orig_start - 1].isalnum(): + orig_start -= 1 + while orig_end < len(preprocessed) and not preprocessed[orig_end].isalnum(): + orig_end += 1 + whitelist_spans.append((orig_start, min(orig_end, len(preprocessed)), preprocessed[orig_start:orig_end])) + + # Extract only alphanumeric characters and normalize homoglyphs + # This removes special characters, emojis, etc. that could be used to bypass the filter + normalized_text, position_map = _extract_alphanumeric_with_mapping(preprocessed) + normalized_lower = normalized_text.lower() + + # Check profanity on normalized text (without special characters) + profanity_spans = _find_profanity_spans_in_original( + normalized_lower, + position_map, + len(preprocessed), + preprocessed + ) + + # Apply censoring to original text + result = list(preprocessed) + for start, end in profanity_spans: + # Check if this span overlaps with a whitelisted word + is_whitelisted = False + for wl_start, wl_end, _ in whitelist_spans: + # Check if spans overlap + if not (end <= wl_start or start >= wl_end): + is_whitelisted = True + break + + if not is_whitelisted: + # Censor the entire span (including any special characters within it) + for pos in range(start, min(end, len(result))): + result[pos] = "*" + + return "".join(result) def contains_profanity(text: str) -> bool: @@ -279,12 +717,50 @@ def contains_profanity(text: str) -> bool: return False _rebuild_dictionary() + + # 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) + normalized_lower = normalized_text.lower() + + # Check phrase patterns on normalized text (to handle special characters) for pattern in _PHRASE_PATTERNS: - if pattern.search(text): + if pattern.search(normalized_lower): return True - if _find_fuzzy_phrase_spans(text, "generic"): + if _find_fuzzy_phrase_spans(normalized_lower, "generic"): return True - return _profanity.contains_profanity(text) + + # Check for profane words as substrings/subsequences (to catch cases like "хуй" in "хууй" or "хуйня") + profane_words = _STATIC_TERMS + substring_spans = _check_profanity_substrings(normalized_text, profane_words) + + if substring_spans: + # Check if any found profanity is not part of a whitelisted word + for span_start, span_end in substring_spans: + is_whitelisted = False + for whitelist_word in _WHITELIST: + normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) + normalized_whitelist_lower = normalized_whitelist.lower() + wl_pos = normalized_lower.find(normalized_whitelist_lower) + if wl_pos != -1: + # Check if profane span is within whitelisted word + if wl_pos <= span_start < wl_pos + len(normalized_whitelist_lower): + is_whitelisted = True + break + if not is_whitelisted: + return True + + # Remove whitelisted words from text before checking profanity + # This allows standalone whitelisted words but still blocks them in phrases + for whitelist_word in _WHITELIST: + # Normalize whitelist word too + normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) + normalized_whitelist_lower = normalized_whitelist.lower() + # Use word boundaries to match whole words only + pattern = re.compile(r"\b" + re.escape(normalized_whitelist_lower) + r"\b", re.IGNORECASE) + normalized_lower = pattern.sub("", normalized_lower) + + return _profanity.contains_profanity(normalized_lower) def contains_sensitive_phrase(text: str) -> bool: From 6d0edd19c3cfb3f934eb4ab5e4ca0854d3a99fad Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 15:46:58 +0300 Subject: [PATCH 33/59] Log both raw input and censored version --- backend/routes/messaging.py | 65 ++++++++++++++++++++++++----------- backend/security/audit.py | 22 ++++++++++-- backend/security/profanity.py | 62 ++++++++++++--------------------- 3 files changed, 86 insertions(+), 63 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 7f2a2cd..adc6348 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -26,7 +26,7 @@ import io import json from better_profanity import profanity as _bp from security.audit import log_access, log_dm, log_public_chat, log_security -from security.profanity import censor_text +from security.profanity import censor_text, contains_profanity from security.rate_limit import rate_limit_per_ip from websocket.utils import authenticate_user @@ -277,6 +277,9 @@ async def _send_message_internal( # Apply profanity filter before storing filtered_content = censor_text(raw_content) escaped_content = html.escape(filtered_content, quote=False) + + # Check if content was censored (use contains_profanity to detect actual profanity) + was_censored = contains_profanity(raw_content) if len(escaped_content) > 4096: raise HTTPException( @@ -368,17 +371,25 @@ async def _send_message_internal( _monitor_public_message_activity(current_user, filtered_content, db) message_payload = convert_message(new_message) - log_public_chat( - "message_created", - 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, - ) + + # 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, + } + + # If content was censored, log both raw and censored versions + if was_censored: + log_fields["raw_content"] = raw_content + log_fields["censored_content"] = filtered_content + + log_public_chat("message_created", **log_fields) return {"status": "success", "message": message_payload} @@ -692,6 +703,10 @@ async def edit_message( original_content = message.content sanitized_content = censor_text(raw_content) escaped_content = html.escape(sanitized_content, quote=False) + + # Check if content was censored (use contains_profanity to detect actual profanity) + was_censored = contains_profanity(raw_content) + if len(escaped_content) > 4096: raise HTTPException(status_code=400, detail="Message too long") @@ -702,15 +717,23 @@ async def edit_message( db.refresh(message) payload = convert_message(message) - log_public_chat( - "message_edited", - 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, - ) + + # 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, + } + + # If content was censored, log both raw and censored versions + if was_censored: + log_fields["raw_content"] = raw_content + log_fields["censored_content"] = sanitized_content + + log_public_chat("message_edited", **log_fields) return {"status": "success", "message": payload} diff --git a/backend/security/audit.py b/backend/security/audit.py index 52bad60..f9e7e64 100644 --- a/backend/security/audit.py +++ b/backend/security/audit.py @@ -204,7 +204,16 @@ def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: attachments = fields.get("attachments") if attachments: lines.append(f"Attachments: {_plural('file', attachments)}") - if fields.get("content"): + + # 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}") @@ -217,7 +226,16 @@ def _render_public_chat(action: str, fields: Dict[str, Any]) -> List[str]: lines.append("Previous content:") for line in unescape(fields["previous_content"] or "").splitlines() or [""]: lines.append(f"| {line}") - if fields.get("content"): + + # 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}") diff --git a/backend/security/profanity.py b/backend/security/profanity.py index d7adffb..b2f3acb 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -713,54 +713,36 @@ def censor_text(text: str) -> str: def contains_profanity(text: str) -> bool: + """ + Check if text contains profanity that would be censored. + Returns True if censor_text would actually censor anything. + """ if not text: return False - _rebuild_dictionary() + # Use censor_text to check if anything would be censored + # This ensures consistency between contains_profanity and censor_text + censored = censor_text(text) - # 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) - normalized_lower = normalized_text.lower() + # Check if any characters were actually censored (changed to asterisks) + # by comparing the original text with the censored version + # We need to account for the fact that the original might already contain asterisks + if censored == text: + return False # No changes, so no profanity - # Check phrase patterns on normalized text (to handle special characters) - for pattern in _PHRASE_PATTERNS: - if pattern.search(normalized_lower): - return True - if _find_fuzzy_phrase_spans(normalized_lower, "generic"): - return True + # If the text changed, check if any non-asterisk characters were replaced + # by comparing character-by-character (excluding positions that were already asterisks) + for i, (orig_char, censored_char) in enumerate(zip(text, censored)): + if orig_char != "*" and censored_char == "*": + return True # A non-asterisk character was censored - # Check for profane words as substrings/subsequences (to catch cases like "хуй" in "хууй" or "хуйня") - profane_words = _STATIC_TERMS - substring_spans = _check_profanity_substrings(normalized_text, profane_words) - - if substring_spans: - # Check if any found profanity is not part of a whitelisted word - for span_start, span_end in substring_spans: - is_whitelisted = False - for whitelist_word in _WHITELIST: - normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) - normalized_whitelist_lower = normalized_whitelist.lower() - wl_pos = normalized_lower.find(normalized_whitelist_lower) - if wl_pos != -1: - # Check if profane span is within whitelisted word - if wl_pos <= span_start < wl_pos + len(normalized_whitelist_lower): - is_whitelisted = True - break - if not is_whitelisted: + # If censored is longer, check the extra characters + if len(censored) > len(text): + for i in range(len(text), len(censored)): + if censored[i] == "*": return True - # Remove whitelisted words from text before checking profanity - # This allows standalone whitelisted words but still blocks them in phrases - for whitelist_word in _WHITELIST: - # Normalize whitelist word too - normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) - normalized_whitelist_lower = normalized_whitelist.lower() - # Use word boundaries to match whole words only - pattern = re.compile(r"\b" + re.escape(normalized_whitelist_lower) + r"\b", re.IGNORECASE) - normalized_lower = pattern.sub("", normalized_lower) - - return _profanity.contains_profanity(normalized_lower) + return False def contains_sensitive_phrase(text: str) -> bool: From abbd3e2db9073d60bea36b61a82a139362c5d0ef Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 16:28:18 +0300 Subject: [PATCH 34/59] Fix reply preview --- .../src/pages/chat/css/ChatInput.module.scss | 18 ---- .../src/pages/chat/css/Message.module.scss | 102 ++++++++---------- .../pages/chat/css/reply-preview.module.scss | 19 ++++ .../pages/chat/ui/right/ChatInputWrapper.tsx | 13 +-- frontend/src/pages/chat/ui/right/Message.tsx | 9 +- 5 files changed, 74 insertions(+), 87 deletions(-) create mode 100644 frontend/src/pages/chat/css/reply-preview.module.scss diff --git a/frontend/src/pages/chat/css/ChatInput.module.scss b/frontend/src/pages/chat/css/ChatInput.module.scss index cdd1f52..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; diff --git a/frontend/src/pages/chat/css/Message.module.scss b/frontend/src/pages/chat/css/Message.module.scss index f1cfe50..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; @@ -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, #9333EA, #6366F1, #3B82F6); - 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/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/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/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 79fdd3d..012629c 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -17,6 +17,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[]; @@ -483,7 +484,7 @@ export function Message({ message, isAuthor, onContextMenu, onReactionClick, isD {!isAuthor && !isDm && (
{message.username} { e.target.src = defaultAvatar; @@ -507,9 +508,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} )} From 3549f9869100a78b663ff6f30ffeb585987e5f55 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 16:32:46 +0300 Subject: [PATCH 35/59] Ensure ping after auth --- frontend/src/core/websocket.ts | 51 +++++++++++++++++++++++++++ frontend/src/pages/auth/LoginForm.tsx | 8 +++++ 2 files changed, 59 insertions(+) diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 0915d19..388c493 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -342,4 +342,55 @@ export function request(payload: WebSocketMessage { + 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/pages/auth/LoginForm.tsx b/frontend/src/pages/auth/LoginForm.tsx index 8e1410d..17f96c1 100644 --- a/frontend/src/pages/auth/LoginForm.tsx +++ b/frontend/src/pages/auth/LoginForm.tsx @@ -12,6 +12,7 @@ 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: { @@ -95,6 +96,13 @@ export function LoginForm({ onSwitchMode }: LoginFormProps) { 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 { From dcad5dbbc2f7eb37f1cc41eb68dd6e38416778b7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 26 Nov 2025 16:41:38 +0300 Subject: [PATCH 36/59] Fix token expiration --- backend/constants.py | 6 ++++-- backend/dependencies.py | 17 +++++++++++++++-- backend/utils.py | 7 ++++--- 3 files changed, 23 insertions(+), 7 deletions(-) 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/dependencies.py b/backend/dependencies.py index c19adee..6ebb55b 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,4 +1,4 @@ -from datetime import datetime +from datetime import datetime, timedelta from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session @@ -66,7 +66,20 @@ def get_current_user( 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() + 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() diff --git a/backend/utils.py b/backend/utils.py index 660b475..ac2cd5a 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -4,16 +4,17 @@ import jwt from typing import Optional, Any import bcrypt -from constants import ACCESS_TOKEN_EXPIRE_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM +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) From 807cbe88d28612fcdc10bfb17488e9e2d56ad467 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 27 Nov 2025 14:28:12 +0300 Subject: [PATCH 37/59] Test new production deployment --- .github/workflows/deploy.yml | 22 +- .husky/post-push | 116 +++++++++++ .vscode/settings.json | 2 +- .vscode/tasks.json | 15 +- package.json | 6 +- scripts/deploy.sh | 364 +++++++++++++++++++++++++++++++++ scripts/generate:env.sh | 1 + scripts/install:pussh.sh | 79 +++++++ scripts/transfer:unregistry.sh | 37 ++++ 9 files changed, 627 insertions(+), 15 deletions(-) create mode 100755 .husky/post-push create mode 100755 scripts/deploy.sh create mode 100755 scripts/install:pussh.sh create mode 100755 scripts/transfer:unregistry.sh diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 68c6570..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. 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 06d3519..b7fe892 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -3,7 +3,7 @@ "**/__pycache__": true, "**/package-lock.json": true, "**/*.module.scss.d.ts": true, - "**/.husky": true, + "**/.husky/_": true, "**/.venv": true, "**/node_modules": true } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 9dd345a..e11d5b1 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,19 @@ "focus": false, "panel": "shared" } + }, + { + "label": "Pre-commit checks", + "type": "shell", + "command": "npm run frontend:typecheck", + "presentation": { + "echo": true, + "reveal": "always", + "focus": true, + "panel": "dedicated", + "clear": true + }, + "problemMatcher": "$tsc" } ] } \ No newline at end of file diff --git a/package.json b/package.json index 5a46993..b679225 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": "bash ./scripts/deploy.sh" }, "files": [ "frontend/build/electron" diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100755 index 0000000..53ebe28 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,364 @@ +#!/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"; exit 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 +} + +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:-}}" +DEPLOY_PATH="${2:-${DEPLOY_PATH:-/home/denis0001-dev/actions-runner/_work/FromChat/FromChat}}" +PLATFORM="${3:-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 + +echo -e "${MAGENTA}${BOLD}🚀 Deployment${NC}\n" + +# ============================================================================ +# BUILD PHASE +# ============================================================================ + +echo -e "${MAGENTA}${BOLD}🔨 Building Docker images${NC}" + +# 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 buildx +if ! docker buildx version > /dev/null 2>&1; then + error "Docker buildx not available. Install Docker Desktop." +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" + fi +done + +success "Build complete! ${#BUILT_IMAGES[@]} image(s) ready" + +# ============================================================================ +# DEPLOY PHASE +# ============================================================================ + +echo -e "\n${MAGENTA}${BOLD}🚀 Deploying to ${SERVER}${NC}\n" + +# Ask for sudo password at the beginning +step "Authentication" +SUDO_PASSWORD="" +while true; do + substep "Sudo password: " -n + read -sp "" SUDO_PASSWORD + echo "" + + 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 + error "Invalid password, please try again" + fi +done + +# Check SSH connection (silent) +if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1; then + warning "SSH key auth not available, will prompt when needed" +fi + +# 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" +TEMP_DIR="/tmp/fromchat-deploy-$$" +ssh "$SERVER" "mkdir -p $TEMP_DIR" > /dev/null 2>&1 + +# Copy docker-compose.yml +if scp "$DEPLOYMENT_DIR/docker-compose.yml" "$SERVER:$TEMP_DIR/docker-compose.yml" > /dev/null 2>&1; then + 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 '' cp $TEMP_DIR/docker-compose.yml $DEPLOY_PATH/deployment/ 2>/dev/null || true +echo '$SUDO_PASSWORD' | sudo -S -p '' chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/docker-compose.yml 2>/dev/null || true +REMOTE_SUDO_SCRIPT + else + ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo cp $TEMP_DIR/docker-compose.yml $DEPLOY_PATH/deployment/ && sudo chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/docker-compose.yml" > /dev/null 2>&1 || true + fi +fi + +# Copy service file +scp "$DEPLOYMENT_DIR/fromchat.service" "$SERVER:$TEMP_DIR/fromchat.service" > /dev/null 2>&1 || { + error "Failed to copy fromchat.service" +} + +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 +echo '$SUDO_PASSWORD' | sudo -S -p '' cp $TEMP_DIR/fromchat.service $DEPLOY_PATH/deployment/ 2>/dev/null +echo '$SUDO_PASSWORD' | sudo -S -p '' chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/fromchat.service 2>/dev/null +REMOTE_SUDO_SCRIPT +else + ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo cp $TEMP_DIR/fromchat.service $DEPLOY_PATH/deployment/ && sudo chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/fromchat.service" > /dev/null 2>&1 || { + error "Failed to copy fromchat.service" + } +fi + +ssh "$SERVER" "rm -rf $TEMP_DIR" > /dev/null 2>&1 || true + +# 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 started" +else + echo "❌ Service failed to start" + sudo_cmd journalctl --no-pager -xeu fromchat -n 30 + exit 1 +fi +REMOTE_SCRIPT + +success "Deployment complete!" diff --git a/scripts/generate:env.sh b/scripts/generate:env.sh index 31a890a..a4070ef 100755 --- a/scripts/generate:env.sh +++ b/scripts/generate:env.sh @@ -8,4 +8,5 @@ cat >> deployment/.env < TURN_SECRET= +DEPLOYMENT_SERVER= 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" + + From 1aa5ca60dbd5deb6c637e68a5dbcae29877401fe Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 27 Nov 2025 16:36:17 +0300 Subject: [PATCH 38/59] Fix auth screen bug --- frontend/src/pages/auth/AuthPage.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/frontend/src/pages/auth/AuthPage.tsx b/frontend/src/pages/auth/AuthPage.tsx index ae5f888..5011317 100644 --- a/frontend/src/pages/auth/AuthPage.tsx +++ b/frontend/src/pages/auth/AuthPage.tsx @@ -48,6 +48,7 @@ export default function AuthPage() { 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) { @@ -68,6 +69,11 @@ export default function AuthPage() { }, [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; @@ -124,6 +130,12 @@ export default function AuthPage() { height: containerHeight === "auto" ? "auto" : `${containerHeight}px`, transition: "height 0.3s ease" }} + onAnimationStart={() => { + + }} + onAnimationEnd={() => { + setContainerHeight("auto"); + }} > {currentMode === "login" ? ( @@ -138,6 +150,9 @@ export default function AuthPage() { transition={slideTransition} onAnimationComplete={handleAnimationComplete("login", "login", enteringElementRef, loginFormRef, setContainerHeight)} className={styles.formWrapper} + style={{ + position: containerHeight === "auto" ? "relative" : "absolute" + }} > switchMode("register")} /> From 2f12bfc4122b0e94fd662c9522bd6cac7d4bc4a0 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 27 Nov 2025 16:39:32 +0300 Subject: [PATCH 39/59] Fix tasks.json --- .vscode/tasks.json | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.vscode/tasks.json b/.vscode/tasks.json index e11d5b1..ae21fc9 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -89,17 +89,16 @@ } }, { - "label": "Pre-commit checks", + "label": "Deploy", "type": "shell", - "command": "npm run frontend:typecheck", + "command": "npm run deploy", "presentation": { "echo": true, "reveal": "always", "focus": true, "panel": "dedicated", "clear": true - }, - "problemMatcher": "$tsc" + } } ] } \ No newline at end of file From add674487c8be48c5ea6562dec668fa2e0aea01f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 27 Nov 2025 21:15:21 +0300 Subject: [PATCH 40/59] Fix profanity filter --- backend/security/profanity.py | 66 +++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 27 deletions(-) diff --git a/backend/security/profanity.py b/backend/security/profanity.py index b2f3acb..261b948 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -15,7 +15,7 @@ BLOCKLIST_PATH.parent.mkdir(parents=True, exist_ok=True) _CUSTOM_RU_TERMS: Set[str] = { "бляд", "блять", "бля", "сука", "суки", "сучка", "мразь", "ебан", "ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда", - "пиздец", "пизд", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", + "пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", "пидоры", "пидорас", "пидорасы", "пидорасов", @@ -317,32 +317,44 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) - # Also check if profane word appears as a subsequence (allowing extra chars) # This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй" - word_chars = list(word_lower) - text_chars = list(normalized_lower) - - # 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 - # 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 - seq_start = None - j = 0 - # Continue from after the start position - i = seq_start + 1 if seq_start is not None else i + 1 - continue - i += 1 + # Only do subsequence matching for words of length 4 or more to avoid false positives + # Use stricter span limits for shorter words to prevent false matches in long legitimate words + if len(word_lower) >= 4: + word_chars = list(word_lower) + text_chars = list(normalized_lower) + # Stricter ratio for shorter words, more lenient for longer words + if len(word_lower) <= 5: + max_span_ratio = 1.5 # Very strict for short words + else: + max_span_ratio = 2.0 # 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 From 620c1db260d334fad27ecadae829374beaf8c899 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 27 Nov 2025 22:50:34 +0300 Subject: [PATCH 41/59] Change profanity filter to reject messages with profanity instead of censoring --- backend/routes/messaging.py | 41 ++- backend/security/profanity.py | 246 ++++-------------- frontend/src/App.tsx | 2 + frontend/src/core/api/messaging.ts | 35 ++- frontend/src/core/components/AlertDialog.tsx | 94 +++++++ .../components/css/alert-dialog.module.scss | 25 ++ frontend/src/core/websocket.ts | 27 +- .../src/pages/chat/ui/right/panels/DMPanel.ts | 48 ++-- .../chat/ui/right/panels/MessagePanel.ts | 40 ++- .../chat/ui/right/panels/PublicChatPanel.ts | 12 +- 10 files changed, 302 insertions(+), 268 deletions(-) create mode 100644 frontend/src/core/components/AlertDialog.tsx create mode 100644 frontend/src/core/components/css/alert-dialog.module.scss diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index adc6348..4d54ae3 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -26,7 +26,7 @@ import io import json from better_profanity import profanity as _bp from security.audit import log_access, log_dm, log_public_chat, log_security -from security.profanity import censor_text, contains_profanity +from security.profanity import contains_profanity from security.rate_limit import rate_limit_per_ip from websocket.utils import authenticate_user @@ -274,12 +274,15 @@ async def _send_message_internal( detail="No content provided" ) - # Apply profanity filter before storing - filtered_content = censor_text(raw_content) - escaped_content = html.escape(filtered_content, quote=False) - - # Check if content was censored (use contains_profanity to detect actual profanity) - was_censored = contains_profanity(raw_content) + # 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" + ) + + # Escape content for safe HTML display + escaped_content = html.escape(raw_content, quote=False) if len(escaped_content) > 4096: raise HTTPException( @@ -368,7 +371,7 @@ async def _send_message_internal( except Exception: pass - _monitor_public_message_activity(current_user, filtered_content, db) + _monitor_public_message_activity(current_user, raw_content, db) message_payload = convert_message(new_message) @@ -384,11 +387,6 @@ async def _send_message_internal( "content": new_message.content, } - # If content was censored, log both raw and censored versions - if was_censored: - log_fields["raw_content"] = raw_content - log_fields["censored_content"] = filtered_content - log_public_chat("message_created", **log_fields) return {"status": "success", "message": message_payload} @@ -701,11 +699,15 @@ async def edit_message( raise HTTPException(status_code=400, detail="Message content cannot be empty") original_content = message.content - sanitized_content = censor_text(raw_content) - escaped_content = html.escape(sanitized_content, quote=False) - # Check if content was censored (use contains_profanity to detect actual profanity) - was_censored = contains_profanity(raw_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") @@ -728,11 +730,6 @@ async def edit_message( "previous_content": original_content, } - # If content was censored, log both raw and censored versions - if was_censored: - log_fields["raw_content"] = raw_content - log_fields["censored_content"] = sanitized_content - log_public_chat("message_edited", **log_fields) return {"status": "success", "message": payload} diff --git a/backend/security/profanity.py b/backend/security/profanity.py index 261b948..c09101f 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -29,7 +29,7 @@ _ADULT_TERMS: Set[str] = { _STATIC_TERMS: Set[str] = set(term.lower() for term in (_CUSTOM_RU_TERMS | _ADULT_TERMS)) -# Words that should never be censored (whitelist) +# Words that should never be flagged as profanity (whitelist) _WHITELIST: Set[str] = { "говно", # Allow this word } @@ -359,85 +359,32 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) - return spans -def _find_profanity_spans_in_original( - normalized_text: str, - position_map: list[int], - original_length: int, - original_text: str -) -> list[tuple[int, int]]: +def _check_profanity_in_normalized(normalized_text: str) -> bool: """ - Find profanity in normalized text and map the spans back to original text positions. + Check if normalized text contains profanity. Uses both better_profanity library and substring matching for better detection. - Returns list of (start, end) tuples in original text coordinates. + Returns True if profanity is found. """ - spans = [] - - if not normalized_text or not position_map: - return spans + 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) - # Combine spans from both methods - all_spans = set() + # If we found any substring matches, there's profanity + if substring_spans: + return True - # From better_profanity censoring - i = 0 - while i < len(censored): - if censored[i] == "*": - span_start = i - while i < len(censored) and censored[i] == "*": - i += 1 - span_end = i - all_spans.add((span_start, span_end)) - else: - i += 1 - - # From substring matching - for start, end in substring_spans: - all_spans.add((start, end)) - - # Map all spans to original positions - for span_start, span_end in all_spans: - if span_start < len(position_map): - orig_start = position_map[span_start] - # Find the end position - use the last mapped position in the span - if span_end > 0 and span_end <= len(position_map): - orig_end = position_map[span_end - 1] + 1 - elif span_end > len(position_map): - orig_end = original_length - else: - orig_end = orig_start + 1 - - # Extend span to include any non-alphanumeric characters between - # the mapped positions in the original text - # Limit extension to prevent over-censoring (max 50 chars each direction) - max_extension = 50 - extension_count = 0 - - # Extend backwards to include any preceding non-alphanumeric - while (orig_start > 0 and - not original_text[orig_start - 1].isalnum() and - extension_count < max_extension): - orig_start -= 1 - extension_count += 1 - - extension_count = 0 - # Extend forwards to include any following non-alphanumeric - while (orig_end < original_length and - not original_text[orig_end].isalnum() and - extension_count < max_extension): - orig_end += 1 - extension_count += 1 - - spans.append((orig_start, min(orig_end, original_length))) - - return spans + return False def _tokenize_with_spans(text: str) -> List[Tuple[int, int, str]]: @@ -604,157 +551,60 @@ def _rebuild_dictionary(force: bool = False) -> None: _blocklist_signature = signature -def _apply_phrase_filters(text: str) -> str: +def _check_phrase_patterns(text: str) -> bool: """ - Apply phrase patterns to text. Patterns are applied to normalized text - (without special characters) and then mapped back to original positions. + 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, position_map = _extract_alphanumeric_with_mapping(text, preserve_spaces=True) + normalized_text, _ = _extract_alphanumeric_with_mapping(text, preserve_spaces=True) normalized_lower = normalized_text.lower() - result = list(text) - censored_positions = set() - - # Apply phrase patterns to normalized text + # Check phrase patterns for pattern in _PHRASE_PATTERNS: - for match in pattern.finditer(normalized_lower): - # Map back to original positions - norm_start = match.start() - norm_end = match.end() - - if norm_start < len(position_map) and norm_end <= len(position_map): - orig_start = position_map[norm_start] - orig_end = position_map[norm_end - 1] + 1 if norm_end > 0 else orig_start + 1 - - # Extend to include special characters - while orig_start > 0 and not text[orig_start - 1].isalnum(): - orig_start -= 1 - while orig_end < len(text) and not text[orig_end].isalnum(): - orig_end += 1 - - # Mark positions for censoring - for pos in range(orig_start, min(orig_end, len(result))): - censored_positions.add(pos) + if pattern.search(normalized_lower): + return True - # Apply fuzzy phrase spans - for start, end in sorted(_find_fuzzy_phrase_spans(normalized_lower, "generic"), reverse=True): - if start < len(position_map) and end <= len(position_map): - orig_start = position_map[start] - orig_end = position_map[end - 1] + 1 if end > 0 else orig_start + 1 - - # Extend to include special characters - while orig_start > 0 and not text[orig_start - 1].isalnum(): - orig_start -= 1 - while orig_end < len(text) and not text[orig_end].isalnum(): - orig_end += 1 - - for pos in range(orig_start, min(orig_end, len(result))): - censored_positions.add(pos) + # Check fuzzy phrase spans + if _find_fuzzy_phrase_spans(normalized_lower, "generic"): + return True - # Apply censoring - for pos in censored_positions: - if pos < len(result): - result[pos] = "*" - - return "".join(result) - - -def censor_text(text: str) -> str: - if not text: - return text - - _rebuild_dictionary() - preprocessed = _apply_phrase_filters(text) - - # Normalize text for whitelist matching (to handle special characters) - normalized_for_whitelist, whitelist_position_map = _extract_alphanumeric_with_mapping(preprocessed) - normalized_for_whitelist_lower = normalized_for_whitelist.lower() - - # Identify and protect whitelisted words (using normalized text) - whitelist_spans = [] - for whitelist_word in _WHITELIST: - # Normalize whitelist word too - normalized_whitelist, _ = _extract_alphanumeric_with_mapping(whitelist_word) - normalized_whitelist_lower = normalized_whitelist.lower() - - # Find in normalized text - pattern = re.compile(re.escape(normalized_whitelist_lower), re.IGNORECASE) - for match in pattern.finditer(normalized_for_whitelist_lower): - # Map back to original positions - if match.start() < len(whitelist_position_map) and match.end() <= len(whitelist_position_map): - orig_start = whitelist_position_map[match.start()] - orig_end = whitelist_position_map[match.end() - 1] + 1 if match.end() > 0 else orig_start + 1 - # Extend to include any special characters - while orig_start > 0 and not preprocessed[orig_start - 1].isalnum(): - orig_start -= 1 - while orig_end < len(preprocessed) and not preprocessed[orig_end].isalnum(): - orig_end += 1 - whitelist_spans.append((orig_start, min(orig_end, len(preprocessed)), preprocessed[orig_start:orig_end])) - - # Extract only alphanumeric characters and normalize homoglyphs - # This removes special characters, emojis, etc. that could be used to bypass the filter - normalized_text, position_map = _extract_alphanumeric_with_mapping(preprocessed) - normalized_lower = normalized_text.lower() - - # Check profanity on normalized text (without special characters) - profanity_spans = _find_profanity_spans_in_original( - normalized_lower, - position_map, - len(preprocessed), - preprocessed - ) - - # Apply censoring to original text - result = list(preprocessed) - for start, end in profanity_spans: - # Check if this span overlaps with a whitelisted word - is_whitelisted = False - for wl_start, wl_end, _ in whitelist_spans: - # Check if spans overlap - if not (end <= wl_start or start >= wl_end): - is_whitelisted = True - break - - if not is_whitelisted: - # Censor the entire span (including any special characters within it) - for pos in range(start, min(end, len(result))): - result[pos] = "*" - - return "".join(result) + return False def contains_profanity(text: str) -> bool: """ - Check if text contains profanity that would be censored. - Returns True if censor_text would actually censor anything. + Check if text contains profanity. + Returns True if profanity is detected. """ if not text: return False - # Use censor_text to check if anything would be censored - # This ensures consistency between contains_profanity and censor_text - censored = censor_text(text) + _rebuild_dictionary() - # Check if any characters were actually censored (changed to asterisks) - # by comparing the original text with the censored version - # We need to account for the fact that the original might already contain asterisks - if censored == text: - return False # No changes, so no profanity + # Check phrase patterns first + if _check_phrase_patterns(text): + return True - # If the text changed, check if any non-asterisk characters were replaced - # by comparing character-by-character (excluding positions that were already asterisks) - for i, (orig_char, censored_char) in enumerate(zip(text, censored)): - if orig_char != "*" and censored_char == "*": - return True # A non-asterisk character was censored + # 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() - # If censored is longer, check the extra characters - if len(censored) > len(text): - for i in range(len(text), len(censored)): - if censored[i] == "*": - return True + # 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 - 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: diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 73e1724..6dd9d08 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ 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 @@ -129,6 +130,7 @@ export default function App() { return authReady && ( +
diff --git a/frontend/src/core/api/messaging.ts b/frontend/src/core/api/messaging.ts index 6cc3261..79064a1 100644 --- a/frontend/src/core/api/messaging.ts +++ b/frontend/src/core/api/messaging.ts @@ -3,6 +3,18 @@ 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 */ @@ -57,8 +69,15 @@ export async function sendMessageWithFiles( body: form }); if (!res.ok) { - const error = await res.text(); - throw new Error(error || "Failed to send message with files"); + 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); } } @@ -71,7 +90,17 @@ export async function editMessage(messageId: number, newContent: string, authTok headers: getAuthHeaders(authToken, true), body: JSON.stringify({ content: newContent }) }); - if (!res.ok) throw new Error("Failed to edit message"); + 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); + } } /** 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/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/websocket.ts b/frontend/src/core/websocket.ts index 388c493..1ea1689 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -15,6 +15,11 @@ 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 * @returns {WebSocket} New WebSocket instance @@ -303,13 +308,29 @@ export function request(payload: WebSocketMessage { - clearTimeout(timeoutId); try { - resolve(JSON.parse(e.data)); + 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.removeEventListener("message", listener); }; websocket.addEventListener("message", listener); diff --git a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts index 0ed2289..d940d78 100644 --- a/frontend/src/pages/chat/ui/right/panels/DMPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/DMPanel.ts @@ -184,34 +184,30 @@ export class DMPanel extends MessagePanel { 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 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 - ); - } - } 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 + ); } } diff --git a/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts b/frontend/src/pages/chat/ui/right/panels/MessagePanel.ts index 322a469..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 "@/state/types"; +import { alert } from "@/core/components/AlertDialog"; + +interface HttpError extends Error { + status?: number; + detail?: string; +} export interface MessagePanelState { id: string; @@ -50,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 @@ -91,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 @@ -332,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 }); + } } } @@ -341,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 72c2e4e..8a6a541 100644 --- a/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts +++ b/frontend/src/pages/chat/ui/right/panels/PublicChatPanel.ts @@ -90,14 +90,10 @@ export class PublicChatPanel extends MessagePanel { protected async sendMessage(content: string, replyToId?: number, files: File[] = []): Promise { if (!this.currentUser.authToken || !content.trim()) return; - try { - 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); - } - } 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); } } From ebf558720c817507aa73887a4b40a32cc13b5c94 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 1 Dec 2025 18:22:44 +0300 Subject: [PATCH 42/59] Improve deployment script --- .gitignore | 1 + scripts/deploy.sh | 236 ++++++++++++++++++++++++++++++++++------------ 2 files changed, 176 insertions(+), 61 deletions(-) diff --git a/.gitignore b/.gitignore index 4510a4d..f6527ab 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 diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 53ebe28..17ef91f 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -19,7 +19,7 @@ BOLD='\033[1m' info() { echo -e "${BLUE}ℹ${NC} $1"; } success() { echo -e "${GREEN}✓${NC} $1"; } warning() { echo -e "${YELLOW}⚠${NC} $1"; } -error() { echo -e "${RED}✗${NC} $1"; exit 1; } +error() { echo -e "${RED}✗${NC} $1"; } step() { echo -e "${CYAN}${BOLD}→${NC} ${BOLD}$1${NC}"; } substep() { if [ "$2" = "-n" ]; then @@ -29,6 +29,44 @@ substep() { 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" @@ -53,8 +91,9 @@ fi # Read server from environment variable (from .env), command line argument, or fallback SERVER="${1:-${DEPLOYMENT_SERVER:-}}" -DEPLOY_PATH="${2:-${DEPLOY_PATH:-/home/denis0001-dev/actions-runner/_work/FromChat/FromChat}}" -PLATFORM="${3:-linux/arm64}" +REPO_NAME="FromChat" +DEPLOY_PATH="~/actions-runner/_work/$REPO_NAME/$REPO_NAME" +PLATFORM="linux/arm64" # Check if server is provided if [ -z "$SERVER" ]; then @@ -68,13 +107,68 @@ if [ -z "$SERVER" ]; then exit 1 fi -echo -e "${MAGENTA}${BOLD}🚀 Deployment${NC}\n" +# ============================================================================ +# SSH AUTHENTICATION +# ============================================================================ + +step "Authentication" +SSH_KEY_FILE="$HOME/.ssh/id_rsa" + +# Ensure ssh-agent is running +if [ -z "$SSH_AUTH_SOCK" ]; then + eval "$(ssh-agent -s)" > /dev/null 2>&1 +fi + +# Add SSH key to agent if not already loaded +if [ -f "$SSH_KEY_FILE" ]; then + # Check if key is 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..." + ssh-add "$SSH_KEY_FILE" 2>/dev/null || true + fi +else + warning "SSH key not found at $SSH_KEY_FILE" +fi + +# Test SSH connection once to cache the key (this will prompt for passphrase if needed) +ssh -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1 || true + +# ============================================================================ +# SUDO AUTHENTICATION +# ============================================================================ + +SUDO_PASSWORD="" +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 # ============================================================================ # BUILD PHASE # ============================================================================ -echo -e "${MAGENTA}${BOLD}🔨 Building Docker images${NC}" +echo -e "\n${MAGENTA}${BOLD}🔨 Building Docker images${NC}\n" # Determine project name if [ -n "$SERVER" ]; then @@ -84,11 +178,48 @@ 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" @@ -209,31 +340,6 @@ success "Build complete! ${#BUILT_IMAGES[@]} image(s) ready" echo -e "\n${MAGENTA}${BOLD}🚀 Deploying to ${SERVER}${NC}\n" -# Ask for sudo password at the beginning -step "Authentication" -SUDO_PASSWORD="" -while true; do - substep "Sudo password: " -n - read -sp "" SUDO_PASSWORD - echo "" - - 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 - error "Invalid password, please try again" - fi -done - -# Check SSH connection (silent) -if ! ssh -o BatchMode=yes -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1; then - warning "SSH key auth not available, will prompt when needed" -fi # Check docker pussh if ! docker pussh --help > /dev/null 2>&1; then @@ -275,42 +381,51 @@ fi # Transfer files step "Transferring deployment files" -TEMP_DIR="/tmp/fromchat-deploy-$$" -ssh "$SERVER" "mkdir -p $TEMP_DIR" > /dev/null 2>&1 - -# Copy docker-compose.yml -if scp "$DEPLOYMENT_DIR/docker-compose.yml" "$SERVER:$TEMP_DIR/docker-compose.yml" > /dev/null 2>&1; then - 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 '' cp $TEMP_DIR/docker-compose.yml $DEPLOY_PATH/deployment/ 2>/dev/null || true -echo '$SUDO_PASSWORD' | sudo -S -p '' chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/docker-compose.yml 2>/dev/null || true -REMOTE_SUDO_SCRIPT - else - ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo cp $TEMP_DIR/docker-compose.yml $DEPLOY_PATH/deployment/ && sudo chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/docker-compose.yml" > /dev/null 2>&1 || true - fi -fi - -# Copy service file -scp "$DEPLOYMENT_DIR/fromchat.service" "$SERVER:$TEMP_DIR/fromchat.service" > /dev/null 2>&1 || { - error "Failed to copy fromchat.service" -} +# 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 -echo '$SUDO_PASSWORD' | sudo -S -p '' cp $TEMP_DIR/fromchat.service $DEPLOY_PATH/deployment/ 2>/dev/null -echo '$SUDO_PASSWORD' | sudo -S -p '' chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/fromchat.service 2>/dev/null +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 cp $TEMP_DIR/fromchat.service $DEPLOY_PATH/deployment/ && sudo chown \$(whoami):\$(whoami) $DEPLOY_PATH/deployment/fromchat.service" > /dev/null 2>&1 || { - error "Failed to copy fromchat.service" - } + ssh "$SERVER" "sudo mkdir -p $DEPLOY_PATH/deployment && sudo chown -R \$(whoami):\$(whoami) $DEPLOY_PATH/deployment" > /dev/null 2>&1 || true fi -ssh "$SERVER" "rm -rf $TEMP_DIR" > /dev/null 2>&1 || true +# 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" @@ -352,13 +467,12 @@ sudo_cmd systemctl daemon-reload sudo_cmd systemctl restart fromchat sleep 3 -if systemctl is-active --quiet fromchat; then - echo "✅ Service started" -else +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!" From 8bda2220c60c7087aaad391ae89c99b52a8b4815 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 1 Dec 2025 18:29:54 +0300 Subject: [PATCH 43/59] Fix rate limiting --- backend/admin_cli.py | 29 +++++ backend/app.py | 28 ++++- backend/routes/moderation.py | 52 +++++++++ backend/security/rate_limit.py | 207 ++++++++++++++++++++++++++++++++- 4 files changed, 314 insertions(+), 2 deletions(-) diff --git a/backend/admin_cli.py b/backend/admin_cli.py index 6afd4f8..ebfab0e 100644 --- a/backend/admin_cli.py +++ b/backend/admin_cli.py @@ -276,6 +276,29 @@ class AdminCLI: 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.", @@ -287,6 +310,8 @@ class AdminCLI: "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.", @@ -347,6 +372,10 @@ class AdminCLI: 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": diff --git a/backend/app.py b/backend/app.py index 1db2b0f..f671f0d 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,3 +1,4 @@ +import asyncio import time from fastapi import FastAPI, Request from fastapi.middleware.cors import CORSMiddleware @@ -68,9 +69,34 @@ 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) diff --git a/backend/routes/moderation.py b/backend/routes/moderation.py index 7eee746..ded5cb8 100644 --- a/backend/routes/moderation.py +++ b/backend/routes/moderation.py @@ -7,12 +7,17 @@ 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"]) @@ -59,4 +64,51 @@ def delete_from_blocklist( 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/security/rate_limit.py b/backend/security/rate_limit.py index 08e5f6d..2e94d23 100644 --- a/backend/security/rate_limit.py +++ b/backend/security/rate_limit.py @@ -1,5 +1,8 @@ from __future__ import annotations +import asyncio +import logging +import time from typing import Callable from fastapi import Request from slowapi import Limiter @@ -7,6 +10,8 @@ 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) @@ -14,6 +19,7 @@ def get_ip_key(request: Request) -> str: # 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 @@ -24,4 +30,203 @@ limiter = Limiter( # 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) \ No newline at end of file + 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 From dae9674e7e8d55bb2cc11f1a7ce8dbbc5f08a55f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 4 Dec 2025 19:46:45 +0300 Subject: [PATCH 44/59] Fix profanity filter --- backend/security/profanity.py | 123 ++++++++++++++++++++++------------ 1 file changed, 81 insertions(+), 42 deletions(-) diff --git a/backend/security/profanity.py b/backend/security/profanity.py index c09101f..7091df4 100644 --- a/backend/security/profanity.py +++ b/backend/security/profanity.py @@ -17,7 +17,7 @@ _CUSTOM_RU_TERMS: Set[str] = { "ебать", "ебёт", "ебет", "ебаная", "ебаная", "уёбок", "уебок", "уебище", "пизда", "пиздец", "хуй", "хуя", "хуе", "хуё", "хуйня", "хер", "гондон", "долбоёб", "долбоеб", "дебил", "член", "проститутка", "проститутки", - "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "пидор", + "урод", "хуесос", "хуесосы", "хуесосов", "хуесоса", "сос", "пидор", "пидоры", "пидорас", "пидорасы", "пидорасов", } @@ -46,6 +46,18 @@ _PHRASE_PATTERNS: Tuple[re.Pattern[str], ...] = ( 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 @@ -182,6 +194,8 @@ _LEET_MAP = { "н": "н", # 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, ...]], ...] = ( @@ -193,6 +207,18 @@ _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) @@ -257,7 +283,10 @@ def _extract_alphanumeric_with_mapping(text: str, preserve_spaces: bool = False) (normalized_text, position_map) where position_map[i] is the original position of the i-th character in normalized_text """ - # First normalize Unicode (composed vs decomposed) + # 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 @@ -316,45 +345,49 @@ def _check_profanity_substrings(normalized_text: str, profane_words: Set[str]) - start = pos + 1 # Also check if profane word appears as a subsequence (allowing extra chars) - # This catches cases like "хуй" in "хууй" or "хU★уй" -> "хууй" - # Only do subsequence matching for words of length 4 or more to avoid false positives - # Use stricter span limits for shorter words to prevent false matches in long legitimate words - if len(word_lower) >= 4: - word_chars = list(word_lower) - text_chars = list(normalized_lower) - # Stricter ratio for shorter words, more lenient for longer words - if len(word_lower) <= 5: - max_span_ratio = 1.5 # Very strict for short words - else: - max_span_ratio = 2.0 # 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 + # 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 @@ -582,7 +615,13 @@ def contains_profanity(text: str) -> bool: _rebuild_dictionary() - # Check phrase patterns first + # 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 From 96cba60804cc943b4b070f767b51052d27221eb3 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 4 Dec 2025 19:49:10 +0300 Subject: [PATCH 45/59] Delete all messages after a spam ban --- backend/routes/messaging.py | 52 ++++++++++++++++++++++++++++++------- 1 file changed, 42 insertions(+), 10 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 4d54ae3..c494a47 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -50,8 +50,8 @@ _BURST_COUNT_THRESHOLD = 20 _SHORT_MESSAGE_LENGTH = 8 _SHORT_MESSAGE_REPEAT_LIMIT = 4 -_recent_message_cache: dict[int, deque[tuple[str, str, float]]] = defaultdict(deque) -_message_rate_cache: dict[int, deque[float]] = defaultdict(deque) +_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] = {} @@ -62,12 +62,23 @@ def _normalize_for_spam(text: str) -> str: return cleaned -def _monitor_public_message_activity(user: User, content: str, db: Session) -> None: +def _monitor_public_message_activity(user: User, content: str, message_id: int, db: Session) -> None: now = time.time() - def suspend(reason: str, event: str, **extra: Any) -> None: + 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() @@ -77,6 +88,7 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N 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: @@ -86,8 +98,8 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N # Rate tracking for burst detection rate_bucket = _message_rate_cache[user.id] - rate_bucket.append(now) - while rate_bucket and now - rate_bucket[0] > _BURST_WINDOW_SECONDS: + 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) @@ -103,12 +115,17 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N 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) @@ -116,21 +133,25 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N 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_same = sum(1 for prev_norm, _, _, _ in history if prev_norm == normalized) prior_similar = sum( 1 - for prev_norm, _, _ in history + 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)) + 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", @@ -138,9 +159,20 @@ def _monitor_public_message_activity(user: User, content: str, db: Session) -> N 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", @@ -371,7 +403,7 @@ async def _send_message_internal( except Exception: pass - _monitor_public_message_activity(current_user, raw_content, db) + _monitor_public_message_activity(current_user, raw_content, new_message.id, db) message_payload = convert_message(new_message) From cc29d2d54659b737bc6989f5a5a47474d706eaa6 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 4 Dec 2025 23:14:01 +0300 Subject: [PATCH 46/59] Implement standalone FromChat Protocol --- .../packages/fromchat-protocol/.gitignore | 7 + .../packages/fromchat-protocol/.npmignore | 8 + .../packages/fromchat-protocol/PUBLISHING.md | 171 ++++++++++++++++++ frontend/packages/fromchat-protocol/README.md | 99 ++++++++++ .../packages/fromchat-protocol/package.json | 54 ++++++ .../fromchat-protocol/src/backup}/backup.ts | 4 +- .../src}/crypto/asymmetric.ts | 6 +- .../fromchat-protocol/src/crypto/index.ts | 7 + .../fromchat-protocol/src}/crypto/kdf.ts | 32 ++-- .../src}/crypto/symmetric.ts | 5 +- .../packages/fromchat-protocol/src/index.ts | 20 ++ .../src/protocol/FromChatProtocol.ts | 102 +++++++++++ .../fromchat-protocol/src/protocol/types.ts | 10 + .../packages/fromchat-protocol/tsconfig.json | 20 ++ frontend/src/core/api/account/index.ts | 4 +- frontend/src/core/api/chats/dm.ts | 91 +++------- frontend/src/core/api/crypto/backup.ts | 6 +- frontend/src/core/api/dm.ts | 4 +- frontend/src/core/api/dmApi.ts | 4 +- frontend/src/core/api/user/auth.ts | 4 +- frontend/src/core/calls/encryption.ts | 4 +- frontend/src/core/calls/webrtc.ts | 2 +- frontend/src/pages/chat/ui/right/Message.tsx | 3 +- frontend/src/utils/crypto/fromchatInit.ts | 26 +++ frontend/tsconfig.json | 11 +- frontend/vite.config.ts | 4 +- package.json | 4 + 27 files changed, 592 insertions(+), 120 deletions(-) create mode 100644 frontend/packages/fromchat-protocol/.gitignore create mode 100644 frontend/packages/fromchat-protocol/.npmignore create mode 100644 frontend/packages/fromchat-protocol/PUBLISHING.md create mode 100644 frontend/packages/fromchat-protocol/README.md create mode 100644 frontend/packages/fromchat-protocol/package.json rename frontend/{src/utils/crypto => packages/fromchat-protocol/src/backup}/backup.ts (94%) rename frontend/{src/utils => packages/fromchat-protocol/src}/crypto/asymmetric.ts (82%) create mode 100644 frontend/packages/fromchat-protocol/src/crypto/index.ts rename frontend/{src/utils => packages/fromchat-protocol/src}/crypto/kdf.ts (99%) rename frontend/{src/utils => packages/fromchat-protocol/src}/crypto/symmetric.ts (88%) create mode 100644 frontend/packages/fromchat-protocol/src/index.ts create mode 100644 frontend/packages/fromchat-protocol/src/protocol/FromChatProtocol.ts create mode 100644 frontend/packages/fromchat-protocol/src/protocol/types.ts create mode 100644 frontend/packages/fromchat-protocol/tsconfig.json create mode 100644 frontend/src/utils/crypto/fromchatInit.ts 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..41082b8 --- /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": "^20.0.0", + "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/src/core/api/account/index.ts b/frontend/src/core/api/account/index.ts index af2318b..9b9fdbc 100644 --- a/frontend/src/core/api/account/index.ts +++ b/frontend/src/core/api/account/index.ts @@ -1,9 +1,7 @@ import { API_BASE_URL } from "@/core/config"; import type { LoginRequest, RegisterRequest, LoginResponse } from "@/core/types"; -import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; -import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; +import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol"; import { b64, ub64 } from "@/utils/utils"; -import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; import { fetchPublicKey, uploadPublicKey, fetchBackupBlob, uploadBackupBlob } from "../crypto"; import type { Headers } from "@/core/types"; diff --git a/frontend/src/core/api/chats/dm.ts b/frontend/src/core/api/chats/dm.ts index 98dc1c5..1feb515 100644 --- a/frontend/src/core/api/chats/dm.ts +++ b/frontend/src/core/api/chats/dm.ts @@ -1,28 +1,19 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "../user/auth"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; import { getCurrentKeys } from "../user/auth"; import { request } from "@/core/websocket"; -import type { SendDMRequest, DmEnvelope, DMEditRequest, DmEncryptedJSON, BaseDmEnvelope, User } from "@/core/types"; +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 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); + 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 }> { @@ -39,27 +30,14 @@ export async function fetchMessages(userId: number, token: string, limit: number } export async function send(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 protocol = getOrInitProtocol(); + const recipientPublicKey = ub64(recipientPublicKeyB64); + + const encrypted = await protocol.encryptMessage(recipientPublicKey, plaintext); + const payload: SendDMRequest = { recipientId: recipientId, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - salt: b64(wkSalt), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext) + ...encrypted }; if (replyToId) payload.replyToId = replyToId; @@ -74,15 +52,16 @@ export async function send(recipientId: number, recipientPublicKeyB64: string, p } 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 = await ecdhSharedSecret(keys.privateKey, ub64(recipientPublicKeyB64)); + 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(); @@ -96,21 +75,14 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: 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 + const serverName = f.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))); + // 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), @@ -128,28 +100,17 @@ export async function sendWithFiles(recipientId: number, recipientPublicKeyB64: } export async function edit(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); + 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, - iv: b64(encMsg.iv), - ciphertext: b64(encMsg.ciphertext), - iv2: b64(wrap.iv), - wrappedMk: b64(wrap.ciphertext), - salt: b64(wkSalt) + ...encrypted } } as DMEditRequest); } @@ -189,6 +150,4 @@ export async function markRead(id: number, authToken: string): Promise { } // Re-export user functions for convenience -export { fetchUsers, searchUsers, fetchUserPublicKey }; - - +export { fetchUsers, searchUsers, fetchUserPublicKey }; \ No newline at end of file diff --git a/frontend/src/core/api/crypto/backup.ts b/frontend/src/core/api/crypto/backup.ts index 3354c5c..bfa2d54 100644 --- a/frontend/src/core/api/crypto/backup.ts +++ b/frontend/src/core/api/crypto/backup.ts @@ -1,12 +1,12 @@ import { API_BASE_URL } from "@/core/config"; -import { getAuthHeaders } from "../user/auth"; 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 = getAuthHeaders(token, true); + const headers = api.user.auth.getAuthHeaders(token, true); const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "GET", headers @@ -25,7 +25,7 @@ export async function fetchBackupBlob(token: string): Promise { export async function uploadBackupBlob(blobJson: string, token: string): Promise { const payload: BackupBlob = { blob: blobJson } - const headers = getAuthHeaders(token, true); + const headers = api.user.auth.getAuthHeaders(token, true); const res = await fetch(`${API_BASE_URL}/crypto/backup`, { method: "POST", headers, diff --git a/frontend/src/core/api/dm.ts b/frontend/src/core/api/dm.ts index b0cc194..7235c81 100644 --- a/frontend/src/core/api/dm.ts +++ b/frontend/src/core/api/dm.ts @@ -1,8 +1,6 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "./account"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; +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"; diff --git a/frontend/src/core/api/dmApi.ts b/frontend/src/core/api/dmApi.ts index b0cc194..7235c81 100644 --- a/frontend/src/core/api/dmApi.ts +++ b/frontend/src/core/api/dmApi.ts @@ -1,8 +1,6 @@ import { API_BASE_URL } from "@/core/config"; import { getAuthHeaders } from "./account"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmEncrypt, aesGcmDecrypt } from "@/utils/crypto/symmetric"; -import { randomBytes } from "@/utils/crypto/kdf"; +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"; diff --git a/frontend/src/core/api/user/auth.ts b/frontend/src/core/api/user/auth.ts index 80b131a..adc656b 100644 --- a/frontend/src/core/api/user/auth.ts +++ b/frontend/src/core/api/user/auth.ts @@ -1,9 +1,7 @@ import { API_BASE_URL } from "@/core/config"; import type { LoginRequest, RegisterRequest, LoginResponse, Headers } from "@/core/types"; -import { generateX25519KeyPair } from "@/utils/crypto/asymmetric"; -import { encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@/utils/crypto/backup"; +import { generateX25519KeyPair, hkdfExtractAndExpand, encodeBlob, encryptBackupWithPassword, decryptBackupWithPassword, decodeBlob } from "@fromchat/protocol"; import { b64, ub64 } from "@/utils/utils"; -import { hkdfExtractAndExpand } from "@/utils/crypto/kdf"; import { fetchPublicKey, uploadPublicKey } from "../crypto/identity"; import { fetchBackupBlob, uploadBackupBlob } from "../crypto/backup"; diff --git a/frontend/src/core/calls/encryption.ts b/frontend/src/core/calls/encryption.ts index 9ebc700..cf61801 100644 --- a/frontend/src/core/calls/encryption.ts +++ b/frontend/src/core/calls/encryption.ts @@ -1,7 +1,5 @@ -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 api from "@/core/api"; import type { WrappedSessionKeyPayload } from "@/core/types"; diff --git a/frontend/src/core/calls/webrtc.ts b/frontend/src/core/calls/webrtc.ts index dbacf14..1edc0f8 100644 --- a/frontend/src/core/calls/webrtc.ts +++ b/frontend/src/core/calls/webrtc.ts @@ -2,7 +2,7 @@ import api from "@/core/api"; import type { CallSignalingMessage, WrappedSessionKeyPayload } from "@/core/types"; import { request } from "@/core/websocket"; import { wrapCallSessionKeyForRecipient, unwrapCallSessionKeyFromSender, rotateCallSessionKey } from "./encryption"; -import { importAesGcmKey } from "@/utils/crypto/symmetric"; +import { importAesGcmKey } from "@fromchat/protocol"; import E2EEWorker from "./e2eeWorker?worker"; import { delay } from "@/utils/utils"; diff --git a/frontend/src/pages/chat/ui/right/Message.tsx b/frontend/src/pages/chat/ui/right/Message.tsx index 012629c..8d9b688 100644 --- a/frontend/src/pages/chat/ui/right/Message.tsx +++ b/frontend/src/pages/chat/ui/right/Message.tsx @@ -6,8 +6,7 @@ import { parse } from "marked"; import { escape as escapeHtml } from "he"; import { useEffect, useState, useRef, useMemo } from "react"; import api from "@/core/api"; -import { ecdhSharedSecret, deriveWrappingKey } from "@/utils/crypto/asymmetric"; -import { importAesGcmKey, aesGcmDecrypt } from "@/utils/crypto/symmetric"; +import { ecdhSharedSecret, deriveWrappingKey, importAesGcmKey, aesGcmDecrypt } from "@fromchat/protocol"; import { useUserStore } from "@/state/user"; import { useProfileStore } from "@/state/profile"; import { StatusBadge } from "@/core/components/StatusBadge"; 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/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 719e614..32018c9 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -70,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: { @@ -89,6 +90,7 @@ export default defineConfig({ }, appType: "spa", optimizeDeps: { + exclude: ["@fromchat/protocol"], esbuildOptions: { target: "es2022" } diff --git a/package.json b/package.json index b679225..56dc600 100644 --- a/package.json +++ b/package.json @@ -67,7 +67,11 @@ "vite-plugin-html": "^3.2.2", "vite-plugin-sass-dts": "^1.3.34" }, + "workspaces": [ + "frontend/packages/fromchat-protocol" + ], "dependencies": { + "@fromchat/protocol": "workspace:*", "electron-squirrel-startup": "^1.0.1", "escape-string-regexp": "^5.0.0", "he": "^1.2.0", From 35b442f827c856e2e0e809908cece85b74486753 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 5 Dec 2025 13:58:27 +0300 Subject: [PATCH 47/59] Fix deployment --- deployment/frontend/Dockerfile | 4 +++- scripts/deploy.sh | 1 + 2 files changed, 4 insertions(+), 1 deletion(-) 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/scripts/deploy.sh b/scripts/deploy.sh index 17ef91f..6f4858b 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -329,6 +329,7 @@ for SERVICE in $SERVICES; do echo "" else error "Build failed for $SERVICE" + exit 1 fi done From e21b2cde7b0cf314bed612905cc8698be28db57a Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 15 Dec 2025 21:52:29 +0300 Subject: [PATCH 48/59] Clean up code --- backend/routes/account.py | 27 ++++++++------------------- 1 file changed, 8 insertions(+), 19 deletions(-) diff --git a/backend/routes/account.py b/backend/routes/account.py index 445930e..620c875 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -39,6 +39,9 @@ def _record_failed_login(identifier: str) -> bool: 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, @@ -49,7 +52,7 @@ 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, @@ -61,7 +64,7 @@ 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) } @@ -177,13 +180,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session = # 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( @@ -219,13 +215,6 @@ def register(request: Request, register_request: RegisterRequest, db: Session = 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( @@ -355,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() @@ -363,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 @@ -567,7 +556,7 @@ 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) From d942ca8dcbd135e0f80f59068b561ae5b9593c3a Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 16 Dec 2025 10:28:14 +0300 Subject: [PATCH 49/59] Update dependencies --- frontend/packages/fromchat-protocol/package.json | 2 +- package.json | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/packages/fromchat-protocol/package.json b/frontend/packages/fromchat-protocol/package.json index 41082b8..c457046 100644 --- a/frontend/packages/fromchat-protocol/package.json +++ b/frontend/packages/fromchat-protocol/package.json @@ -41,7 +41,7 @@ "tweetnacl": "^1.0.3" }, "devDependencies": { - "@types/node": "^20.0.0", + "@types/node": "^25.0.2", "typescript": "^5.0.0" }, "files": [ diff --git a/package.json b/package.json index 56dc600..3b9c951 100644 --- a/package.json +++ b/package.json @@ -52,8 +52,8 @@ "@vitejs/plugin-react": "^5.0.3", "autoprefixer": "^10.4.21", "concurrently": "^9.2.1", - "dotenv-cli": "^10.0.0", - "electron": "^38.1.2", + "dotenv-cli": "^11.0.0", + "electron": "^39.2.7", "husky": "^9.1.7", "postcss": "^8.5.6", "rollup-plugin-visualizer": "^6.0.4", @@ -76,7 +76,7 @@ "escape-string-regexp": "^5.0.0", "he": "^1.2.0", "idb": "^8.0.3", - "marked": "^16.3.0", + "marked": "^17.0.1", "mdui": "^2.1.4", "motion": "^12.23.24", "react": "^19.1.1", From 0645bc5e7f75930fb9f60a3d882feb44507a46bb Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 17 Dec 2025 16:31:53 +0300 Subject: [PATCH 50/59] Fix message editing --- backend/routes/messaging.py | 27 ++++++++++++++++++++------- backend/websocket/handlers.py | 10 ++++------ 2 files changed, 24 insertions(+), 13 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index c494a47..f5e21fd 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -710,15 +710,16 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge } -@router.put("/edit_message/{message_id}") -@rate_limit_per_ip("20/minute") -async def edit_message( - request: Request, +async def _edit_message_internal( message_id: int, edit_request: EditMessageRequest, - current_user: User = Depends(get_current_user), - db: Session = Depends(get_db) -): + 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: @@ -767,6 +768,18 @@ async def edit_message( 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}") async def delete_message( message_id: int, diff --git a/backend/websocket/handlers.py b/backend/websocket/handlers.py index beee083..aa33fc0 100644 --- a/backend/websocket/handlers.py +++ b/backend/websocket/handlers.py @@ -3,13 +3,14 @@ import json import logging import time from typing import Any -from fastapi import HTTPException, WebSocket +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, @@ -211,14 +212,11 @@ async def dmSend(manager: MessaggingSocketManager, websocket: WebSocket, db: Ses @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.""" - from types import SimpleNamespace message_id = data["message_id"] - request: EditMessageRequest = EditMessageRequest.model_validate(data) + edit_request: EditMessageRequest = EditMessageRequest.model_validate(data) - # Create a dummy request object for the HTTP endpoint function - dummy_request = SimpleNamespace() - response = await edit_message(dummy_request, message_id, request, user, db) + response = await _edit_message_internal(message_id, edit_request, user, db) await manager.broadcast({ "type": "messageEdited", "data": response["message"] From b31d31c2d1b9b4d4aebd6dca9129dbeae606efc4 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Fri, 19 Dec 2025 18:27:47 +0300 Subject: [PATCH 51/59] Fix update deduplication --- backend/routes/messaging.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index f5e21fd..0207147 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -1049,6 +1049,7 @@ class MessaggingSocketManager: # 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 @@ -1056,10 +1057,8 @@ class MessaggingSocketManager: self.recent_updates[websocket].add(signature) # Limit recent updates cache size (keep last 100 signatures per websocket) - if len(self.recent_updates[websocket]) > 100: - # Remove oldest entries (simple FIFO by converting to list and keeping last 100) - # Actually, we'll just clear and rebuild on next flush - simpler approach - pass + 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""" From a325de8f3933df3a7e51ffebec0244d3da5ec776 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 29 Dec 2025 12:44:29 +0300 Subject: [PATCH 52/59] Implement Firebase push notifications --- backend/app.py | 10 +++ backend/dependencies.py | 18 ++++- backend/models.py | 10 +++ backend/push_service.py | 144 +++++++++++++++++++++++++++++------- backend/requirements.txt | 1 + backend/routes/messaging.py | 131 ++++++++++++++++++++++++++++++++ scripts/generate:env.sh | 1 + 7 files changed, 288 insertions(+), 27 deletions(-) diff --git a/backend/app.py b/backend/app.py index f671f0d..5bac3a1 100644 --- a/backend/app.py +++ b/backend/app.py @@ -108,6 +108,16 @@ 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) diff --git a/backend/dependencies.py b/backend/dependencies.py index 6ebb55b..d4a126e 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -5,8 +5,10 @@ from sqlalchemy.orm import Session 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(): @@ -23,8 +25,17 @@ def get_current_user( 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", @@ -32,6 +43,7 @@ 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", @@ -60,6 +72,7 @@ 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", @@ -73,6 +86,7 @@ def get_current_user( # 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", @@ -85,6 +99,7 @@ def get_current_user( # 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", @@ -93,6 +108,7 @@ 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", diff --git a/backend/models.py b/backend/models.py index 5f4c71e..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" diff --git a/backend/push_service.py b/backend/push_service.py index a06c36d..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}") @@ -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 1f6b22a..0a78322 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -14,3 +14,4 @@ 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/messaging.py b/backend/routes/messaging.py index 0207147..bc0691b 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -24,12 +24,15 @@ 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") @@ -452,6 +455,97 @@ async def send_message( 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") @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)): @@ -467,6 +561,43 @@ async def get_messages(request: Request, current_user: User = Depends(get_curren } +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( diff --git a/scripts/generate:env.sh b/scripts/generate:env.sh index a4070ef..0f1df8e 100755 --- a/scripts/generate:env.sh +++ b/scripts/generate:env.sh @@ -9,4 +9,5 @@ JWT_SECRET="$(openssl rand -base64 32)" TURN_USERNAME= TURN_SECRET= DEPLOYMENT_SERVER= +FIREBASE_CERT= EOF From e163484fe5f59cce3b7c940b2ff192d64d8035c7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 31 Dec 2025 20:42:44 +0300 Subject: [PATCH 53/59] Improve release script --- .vscode/settings.json | 3 +- deployment/docker-compose.yml | 1 + package.json | 2 +- scripts/deploy.sh | 127 +++++++++++++++++++++++++--------- 4 files changed, 97 insertions(+), 36 deletions(-) diff --git a/.vscode/settings.json b/.vscode/settings.json index b7fe892..fbaf1b5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -6,5 +6,6 @@ "**/.husky/_": true, "**/.venv": true, "**/node_modules": true - } + }, + "python.terminal.activateEnvironment": false } \ No newline at end of file diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index de7e115..c26d5e8 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -8,6 +8,7 @@ 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 - logs:/app/logs diff --git a/package.json b/package.json index 3b9c951..28c2689 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "install:pussh": "bash ./scripts/install:pussh.sh", "prepare": "husky", "generate:env": "bash ./scripts/generate:env.sh", - "deploy": "bash ./scripts/deploy.sh" + "deploy": "dotenv -e deployment/.env -- bash ./scripts/deploy.sh" }, "files": [ "frontend/build/electron" diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 6f4858b..6f7d888 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -113,56 +113,115 @@ fi 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 -# Add SSH key to agent if not already loaded -if [ -f "$SSH_KEY_FILE" ]; then - # Check if key is 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..." - ssh-add "$SSH_KEY_FILE" 2>/dev/null || true - fi -else - warning "SSH key not found at $SSH_KEY_FILE" +# 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 -# Test SSH connection once to cache the key (this will prompt for passphrase if needed) -ssh -o ConnectTimeout=5 "$SERVER" "echo" > /dev/null 2>&1 || true +# 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="" -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 +# 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 - break - else - echo -n " " && error "Invalid password, please try again" fi -done +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 From 0469b932745d6ff59523ce3ef69bb86c7512fc9f Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 31 Dec 2025 21:01:57 +0300 Subject: [PATCH 54/59] Add Caddy to deployment --- deployment/caddy/Caddyfile | 80 +++++++++++++++++++++++++++++++++++ deployment/caddy/Dockerfile | 14 ++++++ deployment/docker-compose.yml | 17 ++++++++ deployment/fromchat.service | 2 +- 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 deployment/caddy/Caddyfile create mode 100644 deployment/caddy/Dockerfile diff --git a/deployment/caddy/Caddyfile b/deployment/caddy/Caddyfile new file mode 100644 index 0000000..25631fb --- /dev/null +++ b/deployment/caddy/Caddyfile @@ -0,0 +1,80 @@ +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} + } + } + } +} diff --git a/deployment/caddy/Dockerfile b/deployment/caddy/Dockerfile new file mode 100644 index 0000000..d28722a --- /dev/null +++ b/deployment/caddy/Dockerfile @@ -0,0 +1,14 @@ +# +# Custom Caddy built with: +# - Rate limit plugin +# + +FROM caddy:2-builder AS builder +RUN xcaddy build \ + --with github.com/mholt/caddy-ratelimit +RUN curl -o /etc/ssl/cloudflare-origin.crt https://developers.cloudflare.com/ssl/static/origin_ca_rsa_root.pem + +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 c26d5e8..9c145aa 100644 --- a/deployment/docker-compose.yml +++ b/deployment/docker-compose.yml @@ -42,6 +42,23 @@ services: - action: rebuild path: package.json + caddy: + build: + context: ./caddy + dockerfile: Dockerfile + restart: unless-stopped + profiles: ["prod"] + 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 diff --git a/deployment/fromchat.service b/deployment/fromchat.service index 7524770..77fd4d3 100644 --- a/deployment/fromchat.service +++ b/deployment/fromchat.service @@ -10,7 +10,7 @@ StartLimitBurst=3 Type=simple User=root Group=root -ExecStart=/bin/docker compose up +ExecStart=/bin/docker compose up --profile prod ExecStop=/bin/docker compose down WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment Restart=always From de34747a345f49fdda19b55a6c058e221437b3f7 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 31 Dec 2025 21:34:16 +0300 Subject: [PATCH 55/59] Fix deployment --- .dockerignore | 12 ++++++++++++ deployment/caddy/Dockerfile | 1 - deployment/docker-compose.yml | 9 +++++---- deployment/fromchat.service | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) create mode 100644 .dockerignore 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/deployment/caddy/Dockerfile b/deployment/caddy/Dockerfile index d28722a..a21acd3 100644 --- a/deployment/caddy/Dockerfile +++ b/deployment/caddy/Dockerfile @@ -6,7 +6,6 @@ FROM caddy:2-builder AS builder RUN xcaddy build \ --with github.com/mholt/caddy-ratelimit -RUN curl -o /etc/ssl/cloudflare-origin.crt https://developers.cloudflare.com/ssl/static/origin_ca_rsa_root.pem FROM caddy:2 diff --git a/deployment/docker-compose.yml b/deployment/docker-compose.yml index 9c145aa..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: @@ -22,7 +22,7 @@ services: path: ../backend/requirements.txt frontend: - build: + build: dockerfile: deployment/frontend/Dockerfile context: .. environment: @@ -47,7 +47,6 @@ services: context: ./caddy dockerfile: Dockerfile restart: unless-stopped - profiles: ["prod"] ports: - "80:80" - "443:443" @@ -63,4 +62,6 @@ volumes: data: name: fromchat-data logs: - name: fromchat-logs \ No newline at end of file + name: fromchat-logs + certs: + name: fromchat-certs \ No newline at end of file diff --git a/deployment/fromchat.service b/deployment/fromchat.service index 77fd4d3..7524770 100644 --- a/deployment/fromchat.service +++ b/deployment/fromchat.service @@ -10,7 +10,7 @@ StartLimitBurst=3 Type=simple User=root Group=root -ExecStart=/bin/docker compose up --profile prod +ExecStart=/bin/docker compose up ExecStop=/bin/docker compose down WorkingDirectory=/home/denis0001-dev/actions-runner/_work/FromChat/FromChat/deployment Restart=always From 16ae35b357e0a25d816ce54bbd078fa2f4c730ee Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Wed, 31 Dec 2025 21:56:23 +0300 Subject: [PATCH 56/59] Fix empty database migration --- backend/app.py | 7 ++++--- backend/migration.py | 23 +++++++++++++++++++---- 2 files changed, 23 insertions(+), 7 deletions(-) diff --git a/backend/app.py b/backend/app.py index 5bac3a1..12ce81e 100644 --- a/backend/app.py +++ b/backend/app.py @@ -20,9 +20,10 @@ 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 @@ -37,10 +38,10 @@ async def lifespan(app: FastAPI): except Exception as e: logger.error(f"Failed to run database migrations: {e}") raise - + try: with SessionLocal() as db: - 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() 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") From 0a430cc3d13df93e718e6f0ca3cb9b322d34df5c Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 4 Jan 2026 13:19:36 +0300 Subject: [PATCH 57/59] Add GetGadgets to Caddyfile --- deployment/caddy/Caddyfile | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/deployment/caddy/Caddyfile b/deployment/caddy/Caddyfile index 25631fb..d9809ab 100644 --- a/deployment/caddy/Caddyfile +++ b/deployment/caddy/Caddyfile @@ -78,3 +78,11 @@ beta.fromchat.ru { } } } + +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 From c7c9d9606d6711dc421915f4d1c5d0bfdff6fd28 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Mon, 5 Jan 2026 16:31:59 +0300 Subject: [PATCH 58/59] Update dependencies --- package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/package.json b/package.json index 28c2689..646a7e2 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@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": "^39.2.7", From c0c1548dda265b6be7784d744a022dc6f47aeb95 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Thu, 8 Jan 2026 21:38:09 +0300 Subject: [PATCH 59/59] Ignore Cursor plans --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index f6527ab..6f7251c 100644 --- a/.gitignore +++ b/.gitignore @@ -575,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